В этом году наша команда принимала участие в VK Hackathon 2018. Мы реализовывали свою идею в рамках трека Технологии. Что из этого вышло, а что — нет, читаем в статье..

Выбор темы проекта

Вообще, кратчайший путь, ведущий к победе на подобных мероприятиях — взять партнерский кейс и вложиться в сильную презентацию, подкрепив ее хоть мало-мальски работающим MVP. К большому сожалению это практически всегда так и VK Hackathon в этом году не стал исключением. Однако, даже понимая все это, ни один из партнерских кейсов не вызывал у команды ничего кроме желания подавить зевок. А пара исключений, находилась далеко за пределами интересующего нас трека «Технологии».

Медицина и машинное обучение

Так как, практически ни один из кейсов нам не подходил, было решено идти со своей идеей, особо ни на что не надеясь. Сделать что-то простое, максимально полезное и с машинным обучением крутилось в голове уже давно. На помощь пришла тема моей диссертации, к которой я все никак не мог подступиться уже пару месяцев. Идея, сделать мобильное приложение для диагностики рака кожи оказалась действительно неплохой. Все известные мне аналоги для iOS можно было пересчитать с помощью пальца одной руки, а бесплатных решений, так я не знал вовсе. Вообщем-то так и родился open source проект Gleam, о разработке которого повествует оставшаяся часть данной статьи.

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

Проблематика и решение

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

Механизм работы приложения прост:

  • Пользователь сканирует проблемный участок кожи камерой смартфона
  • Нейросетевой алгоритм анализирует изображение
  • Пользователь получает результат классификации — диагноз

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

Важная ремарка

Заболевания которые классифицируются термином «рак кожи», можно подразделить на меланому, базально-клеточную карциному и плоскоклеточный рак. Для реализации прототипа диагностической модели нашей командой была выбрана меланома, как один из самых агрессивных видов рака в принципе.

Технологический стэк проекта

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

Автоматическое дифференцирование

Тут никаких сюрпризов не будет, воспользовавшись правом человека единолично отвечающего за ML в команде, я остановил свой выбор на TensorFlow. Никаких долгих рассуждений, анализов бэнчмарков производительности и всего прочего в этом духе здесь не будет. Я просто выбрал одну из самых популярных библиотек от Google в данной области.

К слову TensorFlow штука довольно низкоуровневая, не привязанная к терминологии конкретной модели, поэтому лично я использую TF только в двух случаях. Когда строю какую-нибудь супер простую модель, а-ля линейная регрессия, описывать которую в терминах нейронных сетей, как-то по-извращески. Либо что-то очень комплексное, а-ля GAN (Generative adversarial network), когда типовых решений вообщем-то нет. Во всех остальных случаях, предпочитаю TF не видеть вовсе, поставив на него сверху какой-нибудь front-end.

Keras

Тот самый высокоуровневый нейросетевой API, что стал для нас front-end’ом. Избавив от необходимости разрабатывать поверх TF примитивы для моделей сверточных нейронных сетей. Рассказывать тут особо нечего, очень простая в использовании библиотека, которая позволит вам как из конструктора собрать нужную архитектуру сети. Что-то простое на ней реализовывать — странно, что-то сложное скорее всего не получится, но для нашей задачи она подошла как нельзя кстати. Плюс ко всему Keras .h5 модели без проблем конвертируются в CoreML .mlmodel:

import coremltools

output_labels = ['Benign', 'Malignant']

coreml_model = coremltools.converters.keras.convert(
    'Models/ft_vl_vgg16.h5',
    input_names='image',
    image_input_names='image',
    output_names='output',
    class_labels= output_labels,
    image_scale=1
)

coreml_model.save('Models/FTVGG16.mlmodel')

Подробности работы с coremltools как всегда можно узнать в документации у Apple.

Математическая постановка задачи

Коль с набором инструментов мы определились, пришло время формализовать задачу. Для начала вспомним, что задачей практически любого машинного обучения является по некоторым данным подобрать описывающие их параметры θ лучшим образом. Причем, мера описывающая то, насколько лучшим образом удалось подобрать параметры θ — может быть выражена через теорему Байеса и как следствие будет иметь вероятностный характер:

А раз так, то здесь явно имеет место задача оптимизации. Нам просто необходимо найти значение такого вектора θ при котором значение нашей вероятностной функции будет устремляться к своему максимуму:

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

Конкретно наша задача — является задачей бинарной классификации, а то что нам следует оптимизировать носит название расстояние Кульбака — Лейблера. Данная мера, описывает количество информации, теряющееся при приближении распределения P(эталонное) с помощью распределения Q. Стоит отметить, что оптимизировать расстояние в исходном виде не совсем удобно, поэтому обычно его приводят к форме, называемой перекрестная энтропия. Таким образом функция апостериорной вероятности (или ф-ция потерь) для нашей задачи, принимает вполне осязаемый вид:

Вообщем-то, задача полностью сформулирована и остается ее решить. Решать мы ее будем практически единственным методом локальной оптимизации, а именно градиентным спуском. Из курса мат. анализа нам понадобится понятие градиента функции. Простыми словами градиент укажет нам направление наискорейшего возрастания функции. Посчитать градиент мы сможем взяв вектор производных функции по каждой из компонент. Звучит это страшнее чем выглядит, особенно принимая во внимания что всю дифференциальную математику мы делегируем машине с TensorFlow. Вычислив градиент мы сможем обновить наш вектор θ и пересчитать вероятность, так раз за разом пока не получим приемлемую величину функции потерь. Формула обновления вектора θ обычно принимает подобный вид:

Сверточные нейронные сети

Все, что было сказано выше — справедливо в той или иной степени для любого машинного обучения применительно к нашей задаче. Самое время познакомиться с моделью на которой вышеописанная теория и будет работать. Как понятно из названия нашей моделью стали сверточные нейронные сети, собранные в топологию с названием VGG16.

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

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

Данные для обучения

Для обучения модели мы использовали набор данных  из International Skin Imaging Collaboration: Mellanoma Project ISIC. Для предобработки применялся достаточной простой набор шагов:

  • Визуальная инспекция
  • Обрезка и поворот изображений
  • Удаление не репрезентативных изображений

Данные основного набора были разбиты на два подмножества:

  • Тренировочный набор — 3 тыс. изображений по 1.5 тыс. для каждого класса
  • Валидационный набор — 1 тыс. по 500 изображений для каждого класса

Данные для тестового набора, были взяты из PH2Dataset. Этот набор включает порядка 200 качественных изображений с масками областей повреждений и интереса.

Transfer leaning & Fine tuning

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

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

Текущее значение функции ошибки соответствует точке глобального минимума на правом графике. А значение точности — точке глобального максимума на левом. Как видно из графиков, точность действительно устремляется к 0.9, а ошибка к 0.2.

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

  • Sensivity — описывает процент успешной диагностики заболевания среди больных пациентов.  
  • Specificity — описывает процент успешной диагностики отсутствия заболевания среди здоровых пациентов.

Интеграция модели в iOS приложение

Итак, с моделью покончено, теперь необходимо запустить ее на устройстве. Первое с чем мне пришлось столкнуться после того, как модель была конвертирована в .mlmodel и выгружена в проект — так это с отсутствием функции предобработки изображения из библиотеки Keras, что сводило практически в ноль всю точность классификации. Пришлось быстро писать свой препроцессор выполняющий преобразование захваченного изображения к нужным размерам и в нужной цветовой системе — BRG, используя CIColorKernel из Core Image.

import Foundation
import UIKit

// MARK: - implement image preprocessing for VGG16 model

extension CIImage {
    
    var preprocess: CGImage?  {
        return self.convertToBrg().resizeWith (
            size: CGSize (width: 256, height: 256))
            .cgImage
    }
}


// MARK: - implement swap RGB to BRG system

private extension CIImage {
    
    var brgColorKernel: CIColorKernel? {
        return CIColorKernel(source:
            "kernel vec4 swapRedAndGreenAmount(__sample s) {" +
                "return s.rbga;" +
            "}"
        )
    }
    
    func convertToBrg() -> UIImage {
        let ciOutput = brgColorKernel?.apply(extent: self.extent, arguments: [self as Any])
        return UIImage(cgImage: CIContext().createCGImage(ciOutput!, from: self.extent)!)
    }
}


// MARK: - implement resize image for VGG16 model

fileprivate extension UIImage {
    
    func resizeWith(size: CGSize) -> UIImage {
        let renderer = UIGraphicsImageRenderer(size: size)
        return renderer.image { _ in
            self.draw(in: CGRect.init(origin: CGPoint.zero, size: size))
        }
    }
}

После исправления данного недоразумения, модель корректно заработала и настал черед готовить видео-поток к работе.

Настройка AVCaptureSession из AVKit

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

import Foundation
import AVKit

@objc class VideoCaptureSession: AVCaptureSession {
    
    override init() {
        super.init()
        self.setupInputDevice()
    }
}


// MARK: - implement video capture session sertup

private extension VideoCaptureSession {
    
    func setupInputDevice() {
        guard let captureDevice = AVCaptureDevice.default(for: .video) else { return }
        guard let deviceInput = try? AVCaptureDeviceInput(device: captureDevice) else { return }
        self.addInput(deviceInput)
        self.setupSessionFrameRate(captureDevice)
    }
    
    func setupSessionFrameRate(_ captureDevice: AVCaptureDevice) {
        if let minDuration = getDeviceMinFrameDuration(captureDevice) {
            captureDevice.activeVideoMaxFrameDuration = minDuration
        }
    }
    
    func getDeviceMinFrameDuration(_ captureDevice: AVCaptureDevice) -> CMTime? {
        return captureDevice.activeFormat.videoSupportedFrameRateRanges
            .map { $0.minFrameDuration }.min(by: {$0 < $1 })
    }
}


// MARK: - implement add delegate for module interactor

extension VideoCaptureSession {
    
    func addVideoBufferOutput(delegate: AVCaptureVideoDataOutputSampleBufferDelegate) {
        let dataOutput = AVCaptureVideoDataOutput()
        dataOutput.setSampleBufferDelegate(delegate, queue: DispatchQueue(label: "videoBuffer"))
        self.addOutput(dataOutput)
    }
}

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

CoreML обертка над классификатором

Последний концептуально важный сервис, который следует разобрать это обертка над обученной моделью. Ничего сложного тут нет, я использую VNImageRequestHandler для обработки результатов классификации запроса VNCoreMLRequest на полученном из CMSampleBuffer изображении.

import Foundation
import Vision
import CoreML
import AVKit

// MARK: - implement VGG16 model management

@objc class SkinCancerClassificator: NSObject {
    
    var delegate: SkinCancerClassificatorDelegate?
    
    private lazy var model: VNCoreMLModel = {
        try! VNCoreMLModel(for: FTVGG16().model)
    }()
    
    private lazy var classificationRequest: VNCoreMLRequest = {
        VNCoreMLRequest(model: self.model) { request, error in
            self.didFinishedClassification(with: request.results)
        }
    }()
}


// MARK: - implement handlers for classification process

private extension SkinCancerClassificator {
    
    func didFinishedClassification(with results: Array?) {
        (results as? [VNClassificationObservation]).map {
            self.didFinishedByClass(with: $0)
        }
    }
    
    func didFinishedByClass(with stats: Array) {
        self.delegate?.classificatorFinishedWith (
            stats: stats.map { ($0.identifier, confidence: $0.confidence) }
        )
    }
}


// MARK: - implement run classification process

extension SkinCancerClassificator {
    
    func startClassification(sampleBuffer: CMSampleBuffer) {
        guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
        guard let preprocessImage = CIImage(cvPixelBuffer: pixelBuffer).preprocess else { return }
        try? VNImageRequestHandler(cgImage: preprocessImage).perform([self.classificationRequest])
    }
}

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

import Foundation
import AVKit

class ScreeningInteractor: NSObject, ScreeningInteractorInput {
    
    @objc weak var output: ScreeningInteractorOutput!
    
    var imagesNumber: Int = 0
    var capturedData: Array<(String, Float)> = []
    
    
    @objc weak var classificator: SkinCancerClassificator! {
        didSet { classificator.delegate = self }
    }
    
    @objc weak var captureSession: VideoCaptureSession! {
        didSet { captureSession.addVideoBufferOutput(delegate: self) }
    }
    
}


// MARK: - implement process of classification

extension ScreeningInteractor {
    
    func provideResultsOfClassification(mode: CaptureMode) {
        self.imagesNumber = 0
        self.capturedData = []
        self.setNumberOfCapturedImages(by: mode)
    }
    
    func setNumberOfCapturedImages(by mode: CaptureMode) {
        self.imagesNumber = mode == .live ? 10: 1
    }
    
    func evaluateDiagnosis() {
        let benign = getPredictedScore(by: "Benign")
        let malignant = getPredictedScore(by: "Malignant")
        output.presentResultOfClassification (
            result: malignant > benign ? .high: .low
        )
    }
    
    func getPredictedScore(by status: String) -> Float {
        return self.capturedData.filter { $0.0 == status }
            .reduce(0) { $0 + $1.1 } / Float(capturedData.count)
    }
}


// MARK: - implement skin cancer classification delegate

extension ScreeningInteractor: SkinCancerClassificatorDelegate {
    
    func classificatorFinishedWith(stats: Array<(String, Float)>) {
        stats.forEach { self.capturedData.append($0) }
    }
}


// MARK: - implement capture session video buffer delegate

extension ScreeningInteractor: AVCaptureVideoDataOutputSampleBufferDelegate {
    
    func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
        self.provideClassificationRequest { self.classificator.startClassification (sampleBuffer: sampleBuffer) }
    }
    
    func provideClassificationRequest(classificationRequest: () -> Void) {
        guard self.imagesNumber > 0 else { return }
        classificationRequest(); self.imagesNumber -= 1
        if self.imagesNumber == 0 { evaluateDiagnosis() }
    }
}

Open Source

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

Результаты проекта

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

Автор фото: @yodezeen

Для связи по проекту: Telegram, LinkedIn

Если вы нашли ошибку, пожалуйста, выделите фрагмент текста и нажмите Ctrl+Enter.


  • А чем конкретно можно помочь ios разработчику?

    • Alexey Karataev

      Здравствуйте, вы можете оставить свои координаты мне в telegram либо в linkedin. После релиза мы соберем доску с задачами на вторую версию, из которой вы самостоятельно сможете выбрать интересные для вас улучшения.