Третья жизнь пет-проекта по распознаванию рукописных цифр

38dfaf39dd89abc5ad311b14b487cab7.png

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

Вступление

Пять лет назад, когда я получил свою первую работу в DS, я хотел как можно быстрее набрать побольше опыта. Среди прочего, я работал над пет-проектом: приложением на Flask, которое позволяло пользователям рисовать цифры и распознавать их с помощью ML-модели. На его разработку у меня ушло несколько месяцев, но оно того стоило как с точки зрения прокачивания навыков, так и в плане развития карьеры. Я даже писал о нём статью на хабре.

Два года спустя я опубликовал новую версию с различными улучшениями; например, я использовал OpenCV для распознавания отдельных цифр, а модель была расширена до 11 классов, предсказывающих не-цифры.

Эти два приложения были развернуты на Heroku с использованием бесплатного плана, но некоторое время назад эти планы были прекращены. Я не хотел, чтобы проект канул в лету, поэтому решил сделать новую версию. Делать просто передеплой проекта на новой платформе было бы неинтересно, поэтому я обучил модель YOLOv3 с нуля на 12 классах. Несмотря на то, что это всего лишь пет-проект, в нём было много проблем, которые встречаются и в реальных проектах. В этой статье я хочу поделиться своим опытом работы над этим проектом, начиная со сбора данных и заканчивая деплоем.

Вот ссылка на само приложение.

Сбор и подготовка данных

Сбор и разметка данных — важная часть любого проекта. Благодаря предыдущим версиям этого приложения у меня был датасет из примерно 19 тыс. изображений, которые хранились на Amazon S3. Лейблы для этих изображений были изначально сгенерированы моими моделями, и я знал, что часть из них ошибочны, ибо никакие модели не могут быть идеальными. По моим оценкам, уровень ошибок составлял около 10%, что означало, что около 2 тысяч изображений имели неправильные метки.

06051d26fb7fedbed5ff0ccd4a0ebec4.jpeg

Помимо ошибок в самих лейблах, было много кейсов, когда мне самому было непонятно, что же показано на картинке. Например, люди иногда рисовали цифры так, что было трудно определить, что изображено (2 или 8, 1 или 7), или рисовали несколько цифр на одном изображении, что добавляло дополнительную головную боль. Кроме того, в моей предыдущей модели был реализован класс «other» для распознавания объектов, не являющихся цифрами, но мне все равно нужно было проверить все метки.

13f3c2f6dd6d71085d5add2c8024d44f.jpeg

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

Image classification

Когда я начал работать над своим обновленным проектом по распознаванию цифр, я начал с обучения модели CNN на Pytorch на моем MacBook. Ради интереса также обучил модель ViT, используя этот гайд. Обе модели были обучены с помощью MPS Pytorch, что намного быстрее тренировки на CPU (пусть и уступает полнцоценным GPU).

Ранее я уже разработал пайплайн для тренировки моделей на PyTorch-lightning и Hydra, и я смог его легко допилить для этого проекта. Код можно посмотреть здесь.

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

Стоит отметить, что на данный момент у меня было 12 классов, которые модели должны были распознать: 10 для цифр, один для «other» и последний класс, который я назвал «censored». Думаю, что вы легко сможете найти примеры на картинке ниже;) У меня собралось немало примеров, и модели смогли распознать этот класс весьма хорошо.

Хотя гонять эти эксперименты была весело, они были лишь промежуточным шагом на пути к моей цели — обучению object detection.

Разметка данных для object detection

Как я уже упоминал, моей целью в этом проекте было обучение модели object detecion, для чего требовались bounding box для каждого объекта на каждом изображении. Для начала я использовал cv2.findContours и cv2.boundingRect из OpenCV для построения bounding box вокруг объектов на изображениях. Чтобы упростить первый шаг, я сначала работал только с изображениями, содержащими один объект.

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

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

fae2176635871bee97448bb25b121583.jpeg

После небольшого ресерча я нашёл (точнее мне подсказали), что https://www.makesense.ai/ является полезным инструментом для разметки bbox. На просмотр и разметку всех изображений ушло несколько часов, но в итоге я получил разметку bbox для 16,5 тыс. изображений.

Одна из проблем, с которой я столкнулся при маркировке данных, заключалась в том, чтобы решить, что размечать, а что нет. Например, на скриншоте ниже не очень понятно, что является »0», а что является каким-то мусором.

f1520213bef3f9da016140b172ecfc00.jpeg

Тренировка YOLOv3

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

d8f5e232c735605dac4897f21e57a000.jpeg

Однако, когда я начал обучать модель на всех изображениях, все пошло не так. Метрики были плохими, а иногда градиенты даже взрывались. Предсказанные bbox были кривыми даже на глаз.

115c37c6a8a3554057dcadbd29253507.jpeg

Я долго дебажил, чтобы понять в чём же были ошибки.

Одна из проблем, с которой я столкнулся, была связана с аугментациями: некоторые ауги из библиотеки Albumentations приводили к искажению bbox. Вот старое issue на GitHub об этой проблеме. В результате я начал использовать imgaug для аугментаций и использовал albumentations только на последнем этапе нормализации и ресайза.

import imgaug.augmenters as iaa
from imgaug.augmentables.bbs import BoundingBox, BoundingBoxesOnImage
import numpy as np
import albumentations

# an example of bbox
original_bboxes = [[14, 17, 28, 75, 1], [63, 74, 63, 69, 2], [140, 102, 39, 78, 3]]
# an example of image
max_x = 200
max_y = 200
original_image = np.random.randint(0, 255, size=(max_x, max_y, 3))

# creating bounding boxes in imgaug format
bbi = []
for b in [[b[0], b[1], b[0] + b[2], b[1] + b[3], b[4]] for b in original_bboxes]:
    bbi.append(BoundingBox(x1=b[0], y1=b[1], x2=b[2], y2=b[3], label=b[4]))

bbs = BoundingBoxesOnImage(bbi, shape=original_image.shape)

# defining the transformation
seq = iaa.Sequential([
    iaa.Affine(rotate=(-45, 45), scale=(0.9, 1.0),
               translate_px={"x": (-20, 20), "y": (-20, 20)}, cval=255
               )
])
# applying the transformation
im, bbs_aug = seq(image=im, bounding_boxes=bbs)

# convert the bounding boxes into yolo format
bboxes = [[b_.center_x / max_x, b_.center_y / max_y,
                       b_.width / max_x, b_.height / max_y, b_.label] for b_ in bbs_aug.bounding_boxes]

# define albumentations transforms
albumentations.Compose([albumentations.Resize(always_apply=False, p=1, height=192, width=192, interpolation=1),
                        albumentations.Normalize(always_apply=False, p=1.0, mean=(0, 0, 0), std=(1, 1, 1), max_pixel_value=255.0),
                        albumentations.pytorch.transforms.ToTensorV2(always_apply=True, p=1.0, transpose_mask=False)],
                       p=1.0,
                       bbox_params={'format': 'yolo', 'label_fields': None, 'min_area': 0.0, 'min_visibility': 0.0, 'check_each_transform': True},
                       keypoint_params=None, additional_targets={})

Еще одна проблема, которую я обнаружил, связана с форматом bbox: я ошибся и использовал формат coco, в то время как код модели ожидал, что они будут в формате yolo. Исправление этого косяка помогло, но метрики модели все равно были недостаточно высокими.

Чтобы эту ошибку не повторили другие, давайте рассмотрим эти форматы подробнее:

https://albumentations.ai/docs/getting_started/bounding_boxes_augmentation/https://albumentations.ai/docs/getting_started/bounding_boxes_augmentation/

# an example of converting the bounding boxes between different formats.

import numpy as np
import albumentations

# an example of bbox in coco format: x_min, y_min, width, height
coco_bboxes = [[14, 17, 28, 75], [63, 74, 63, 69], [140, 102, 39, 78]]

# convert coco format to pascal_voc format
pascal_voc_bboxes = [[box[0], box[1], box[0] + box[2], box[1] + box[3]]] for box in coco_bboxes]

max_x = 200
max_y = 200
# convert coco format to yolo format
yolo_bboxes = [[(box[0] + box[2] / 2) / max_x,
                (box[1] + box[3] / 2) / max_y,
                box[2] / max_x, box[3] / max_y] for box in coco_bboxes]

После дальнейших экспериментов я обнаружил последнюю проблему: при обучении моделей классификации изображений я извлекал нарисованные цифры с помощью OpenCV и делал ресайз до 32×32 или 64×64. Это означало, что цифры занимали все пространство на изображении. Однако когда я начал обучать модели object detection, я брал весь canvas и ресайзил его до 64×64. В результате многие объекты становились слишком маленькими и кривыми для эффективного распознавания. Увеличение размера изображений до 192×192 помогло улучшить работу модели.

Если интересно, вот ссылка на репорт Weight and Biases.

Ранее я упоминал, что обучал модели классификации изображений с помощью Pytorch MPS на MacBook. Однако, когда я попытался обучить модель object detecion таким же образом, я столкнулся с некоторыми проблемами в Pytorch MPS. Одна внутренняя операция падала, поэтому мне пришлось перейти на CPU. На GitHub есть специальное issue, где люди могут поделиться подобными проблемами.

При обучении на изображениях размером 64×64 это работало достаточно быстро (хотя и занимало 15 минут), но увеличение размера изображения до 192×192 делало обучение непомерно медленным. В результате я решил использовать Google Colab. К сожалению, бесплатной версии оказалось недостаточно, поэтому мне пришлось приобрести 100 кредитов. Одна эпоха на Colab заняла всего 3 минуты. Запуска нескольких экспериментов было достаточно, чтобы получить хорошие метрики.

253c65f0d978d3d94a0d5aef02094420.jpeg

Разработка и деплой

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

Streamlit

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

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

Весь код приложения можно увидеть тут.

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

Deployment

Изначально я планировал захостить приложение на облаке streamlit, поскольку это отличная платформа для быстрого развертывания и шаринга небольших приложений. Я успешно развернул приложение на streamlit cloud, но когда я поделился им в одном чате, оно быстро уперлось в лимиты. Это означало, что мне нужно было найти альтернативное решение.

3d35c03a309becaa845d76737e6d1fb7.jpeg

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

Тогда я вспомнил о Hugging Face Spaces, платформе, специально разработанной для деплоя ML-приложений. Я смог легко развернуть свое приложение на этой платформе (ушло меньше часа), и оно заработало без каких-либо проблем.

CI/CD

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

Я установил pre-commit hooks с black, mypy, flake8 и прочим.

b5010c4ccfb9d2d86b66be77bb912cc7.jpeg

Запретил прямой пуш в мастер.

62c660aaeb232683fb086d80a3d40169.jpeg

При создании PR триггерятся проверки deepsource и пайплайн на Github Actions.

ec64661b3e08647e1cb707fb940981fa.jpeg

После успешного мерджа PR, триггерится ещё один пайплайн — для синхронизации кода с репо на Hugging Face Spaces.

fb859c836dcd5c335c98e534bfabf57f.jpeg

Фейл со Style Transfer

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

f2628a4d30139497601c1f5abc97b7db.jpeg

Итоги

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

Этот проект был ценным и приятным опытом обучения для меня, и я надеюсь, что вы также нашли его интересным :)

Дополнительные ссылки:

© Habrahabr.ru