Глубокое обучение: разбираемся со свертками

Внимание! Данная статья является переводом. Оригинал можно найти по этой ссылке.

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

Изучение сверток, да еще и на ранних этапах, может отнимать много сил и времени, особенно когда дело доходит до таких понятий как “кернел” (“ядро”), “фильтр”, “канал” и т.д. и т.п. Но усилия того стоят: свертки — невероятно мощный и высокомасштабируемый инструмент.

В этой статье мы заглянем под капот сверточных сетей и шаг за шагом разберем механизмы, лежащие в их основе. Проведем аналогии с обычными полносвязными (каждый-к-каждому) сетями. Изучим, как в свертках “накапливаются” визуальные иерархии, что делает их идеально подходящими для извлечения информации из изображений.

Двумерные свертки

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

Рис. 1. Обычная свертка [1]

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

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

Теперь давайте посмотрим на все это в контексте полносвязного слоя обычной нейронной сети. В примере выше имеется 5×5=25 входных и 3×3=9 выходных характеристик. Если бы рассматривался стандартный полносвязный слой, у нас получилась бы весовая матрица на 25×9=225 параметров; ответ этого слоя формировался бы посредством суммирования произведений каждого входного сигнала на некоторый весовой коэффициент. (Функция активации в целях упрощения опущена.) Свертки позволяют совершать подобные операции только над девятью параметрами — с каждым выходным сигналом.

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

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

Некоторые часто используемые техники

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

Паддинг

Присмотритесь к анимации, расположенной выше (рис. 1). Обратите внимание на то, как в процессе передвижения кернела края исходной матрицы “срезаются”: краевые пикселы никогда не попадают в центр кернела, что обусловлено принятыми правилами передвижения. Это не всегда приемлемо, особенно когда размеры исходной и результирующей матриц должны быть одинаковыми.

Рис. 2. Паддинг [1]

Указанная проблема решается с помощью паддинга, который является суть “ложными” пикселами, значение которых обычно равняется нулю (такой паддинг обычно именуют “нулевым”). Благодаря дополнительному пространству вокруг исходной матрицы кернел получает возможность обойти краевые пикселы в полной мере (см. рис. 2). Обратите внимание на то, что размеры исходной и результирующей матриц совпадают.

Страйд

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

Один из способов достижения такого результата — использование пулингового (сборочного) слоя, который по своей сути представляет из себя обычную “решетку для просеивания”, способствующую редуцированию числа импульсов.

Другой способ — использование страйда.

Рис. 3. Страйд со значением 2 [1]

В основе принципа страйда лежит идея пропуска кернелом некоторого числа шагов при его перемещении. Страйд со значением 1 говорит о том, что кернел смещается на один пиксел, что приводит нас к стандартной свертке. При страйде со значением 2 кернел смещается на два пиксела, уменьшая размер результирующей матрицы по сравнению с предыдущим вариантом примерно вдвое. И т.д.

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

Многоканальная версия

Все, что мы обсуждали выше, применительно только для изображений с одним входным каналом. Но, как нам известно, изображения обычно имеют три канала (RGB: Red, Green, Blue); чем более глубокой является нейронная сеть, тем большее количество каналов она способна обрабатывать. О каналах легче всего думать как о неких “составляющих” изображения, каждая из которых либо подавляет, либо усиливает определенные его черты.

Рис. 4. Большую часть времени мы имеем дело с тремя каналами(Credit: Andre Mouton)
Рис. 5. Коллекция кернелов в качестве фильтра

Теперь есть возможность проследить разницу между моноканальной и поликанальной версиями нейронной сети.

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

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

Каждый фильтр в сверточном слое генерирует один и только один выходной канал. Принцип работы фильтра описан ниже.

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

Рис. 6. Каждый кернел соответствует определенному каналу

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

Рис. 7. Получение результирующего канала

Для окончания процесса к полученному результату нужно применить смещение (bias). В сверточных нейронных сетях смещение применяют к каждому выходному фильтру; эта операция является обычным сложением смещения с каждым из сигналов результирующего (выходного) канала (см. рис. 8).

Рис. 8. Смещение

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

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

Свертки — все еще линейные преобразования

Механизм работы сверточных слоев в некоторой степени прост. Но, как бы вы ни старались, вряд ли получится провести какие-либо существенные аналогии с фид-фовард слоями (слоями прямого распространения, или же слоями с прямыми связями; все это одно и то же). Также мало вероятно, что вам удастся получить внятные обоснования, почему сверточные нейронные сети так хорошо подходят для обработки изображений. Что-то в таком духе: работает — и точка!

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

На рис. 9 представлена матрица W синаптических весов между входным и выходным слоями рассматриваемой нейронной сети:

Рис. 9. Матрица синаптических весов между входным и выходным слоями

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

Если бы для нашей задачи мы использовали кернел K размерностью 3×3 для входной матрицы 4×4 с целью получения на выходе матрицы 2×2, соответствующая матрица весов приняла бы такой вид:

Рис. 10. Псевдоматрица весов, сформированная перемещением кернела

Обратите внимание на сходство данной матрицы с той, которая приведена на рис. 9. Но не стоит заблуждаться! Хотя и размерности матриц идентичны, они образованы применением различных подходов [2]. В первом случае матрица является характеристикой множественных связей между одномерными слоями, во втором же случае — характеристикой работы кернела в двумерном пространстве.

Мы пришли к выводу, что свертки — это тоже линейные преобразования, но в то же время они отличаются от тех, к которым мы привыкли в “обычных” нейронных сетях. Для входной матрицы на 64 элемента существует 9 параметров, которые будут использоваться повторно (параметры кернела). Каждый узел выходной матрицы образуется на основе только некоторого числа входных сигналов — тех, которые попадают в поле зрения кернела. Множественные связи разрушены нулевыми весами; это видно на рис. 10.

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

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

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

Посмотрите на рис. 10. В этой матрице нет необходимости “обучать” все 64 параметра, так как многие из них обращены в нуль. Кернел имеет размерность 3×3, и поэтому обучению будут поддаваться только 9 его параметров. Это имеет значение, особенно когда тренировочные данные из MNIST, имеющие 784 входа, заменяются реальными входными образами размерностью 224×224×3 (150528 входов).

Даже если ваша полносвязная система будет в целях оптимизации делить входные матрицы пополам, обрабатывая за один подход 75 264 сигнала, в ней будет более 10 миллиарда параметров! Для сравнения: у полносвязной нейронной сети ResNet-50 где-то 20 миллионов параметров.

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

Ответ лежит в комбинациях характеристик нейронной сети. А правильно подобранная комбинация — результат применения эффективного механизма обучения.

Общие черты

P.S. В оригинальной статье принцип общих черт называется “locality”. В данном контексте это означает рассмотрение некоторой совокупности пикселов, далее именуемой “пучком”, с целью поиска так называемых “аномалий”. Аномалия проявляется в тот момент, когда пикселы из пучка отличаются друг от друга.

Ранее мы обсудили, что:

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

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

Если бы сверточная нейронная сеть использовалась для анализа какого-нибудь другого типа данных, например, статистики установки мобильного приложения, могла бы случиться настоящая катастрофа. А все потому, что данные подобного рода не имеют локальных (распределенных) характеристик, которые в сумме формируют полную картину.

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

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

Эта идея лежит в основе самых первых методов компьютерного зрения. Например, для выявления границ объектов используется оператор (фильтр) Собеля — кернел с фиксированными параметрами. Оператор Собеля по своей сути является обычной одноканальной сверткой (см. рис. 11).

Рис. 11. Использование свертки для выявления границ объектов

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

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

В основе оператора Собеля лежит кернел размерностью 3×3. Это позволяет находить локальные аномалии. Перемещение данного кернела вдоль всего изображения приводит к нахождению всех границ, которые способна обнаружить конкретная модель.

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

Существует целый раздел машинного обучения, направленный на разработку способов интерпретации откликов нейронных сетей. Один из самых сильных инструментов, используемых в этой области — визуализация характеристик посредством оптимизаций [3]. Его идея проста: нужно проводить оптимизацию изображения, обычно с некоторым первоначальным шумом, до момента достижения максимального эффекта от используемого фильтра.

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

Рис. 12. Визуализация характеристик некоторых каналов первого сверточного слоя GoogLeNet [3]. Каждый фильтр занимается определением некоторой разновидности границы, что в комплексе дает вполне удовлетворительный результат.
Рис. 13. Визуализация характеристик 12-го канала второй и третьей сверток [3]

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

Есть одно “но”. Чем глубже “копает” нейронная сеть, тем сложнее извлечь из данных полезные характеристики без дополнительных преобразований этих данных. К примеру, вряд ли получится определить лицо на участке размерностью 3×3 пиксела. Тут в силу вступает идея рецептивного поля.

Рецептивное поле

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

Принцип общих черт (locality), приведенный в предыдущем разделе, определяет вид матрицы выходных сигналов посредством “свертывания” пучка пикселов.

Рецептивное поле — часть матрицы входных сигналов, подвергаемая “свертыванию”.

Плотность рецептивного поля — количество информации, способное поместиться в пределах рассматриваемого пучка пикселов.

Процесс свертывания по своей сути заключается в уплотнении количества данных, заложенных в один пиксел. Представьте ситуацию, в которой вы располагаете матрицей некоторых импульсов размерностью 3×3 (т.е. всего 9 единиц информации). Использование сверточного слоя с единственным кернелом размерностью 2×2 “свернет” входные импульсы в матрицу размерностью 2×2, что даст 4 единицы информации. Как видите, информация, хранимая ранее в девяти ячейках, теперь помещается в меньшем пространстве, а именно в четырех ячейках. Но без потерь не обойтись.

Как вы помните, страйд — это фиксированный шаг перемещения кернела вдоль матрицы входных сигналов. Важно помнить, что страйд — это хороший способ пулинга (объединения с целью уменьшения размерности) сигналов. Если посмотреть на процесс пулинга под особым углом, в нем можно увидеть ассимиляцию сигналов с их окружением [1].

Рис. 14. Процесс свертывания с применением кернела размерностью 3×3, страйда со значением 2 и паддинга со значением 1

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

Даже если мы будем продолжать использовать кернел размерностью 3×3, он с каждым уровнем сжатия будет получать все больше информации. Для расширения поля зрения нет смысла увеличивать его размерность. Все дело в ассимиляции: на небольшом участке “свернутых” данных будут проявляться характеристики большого числа ассимилированных в них сигналов (см. рис. 15).

Рис. 15. Ассимиляция пикселов с его окружением

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

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

Обратите внимание на одну вещь. Если вы знакомы с расширяемыми свертками (dilated convolutions), вы должны понимать, что описанное выше не имеет к ним никакого отношения. Оба эти подхода направлены на уплотнение рецептивного поля. Разница состоит в том, что расширяемая свертка решает задачу посредством дополнительного слоя нейронной сети, тогда как описанный в этом разделе подход основан на использовании страйдов (а также нелинейных преобразований в промежутках).

Рис. 16. Визуализация характеристик каналов сверточной нейронной сети. Обратите внимание на их постепенное усложнение. [3]

Уплотнение рецептивного поля дает сверточным слоям возможность превращать низкоуровневые характеристики (линии, границы) в характеристики более высокого уровня (кривые, текстуры). Это видно на примере визуализации характеристик канала 31 из слоя mixed3a (см. рис. 16).

Используя операцию пулинга/страйдирования, нейронная сеть по мере “погружения” формирует все более сложные детекторы высокоуровневых характеристик (фрагментов, паттернов). На рис. 16 хорошо заметно постепенное усложнение детекторов.

Представьте ступенчатое редуцирование исходного изображения размерностью 224×224 нейронной сетью с пятью сверточными блоками, которое на выходе дает матрицу размерностью 7×7. Каждый пиксел результирующих данных является характеристикой сетки пикселов размерностью 32×32, соответствующей исходному изображению. Вот это да!

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

Сверточная нейронная сеть обычно начинается с небольшого количества фильтров (64 в случае GoogLeNet), которые занимаются обнаружением низкоуровневых характеристик, и постепенно переходит к большему числу фильтров (1024 на финальном этапе свертки), каждый из которых занимается поиском каких-то конкретных высокоуровневых образов. Все завершатся пулинговым слоем, который “схлопывает” каждую сетку пикселов размерностью 7×7 в единственный пиксел. Соответственно этот пиксел— детектор характеристик с настолько плотным рецептивным полем, что он характеризует исходное изображение.

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

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

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

Финальная заметка о состязательных атаках

P.S. В оригинальной статье состязательные атаки называются “adversarial attacks”.

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

Самая распространенная проблема — состязательные атаки [4]. “Атака” такого рода заключается в том, что в изображение нарочно вводится шум, призванный сбить нейронную сеть с толку.

Рис. 17. Для человека очевидно, что на обоих картинках изображена панда. Нейронная сеть же думает иначе… [4]

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

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

Заключение

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

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

Полезные ссылки

  1. A guide to convolution arithmetic for deep learning
  2. CS231n Convolutional Neural Networks for Visual Recognition — Convolutional Neural Networks
  3. Feature Visualization — How neural networks build up their understanding of images (of note: the feature visualizations here were produced with the Lucid library, an open source implementation of the techniques from this journal article)
  4. Attacking Machine Learning with Adversarial Examples

Дополнительные материалы

  1. fast.ai — Lesson 3: Improving your Image Classifier
  2. Conv Nets: A Modular Perspective
  3. Building powerful image classification models using very little data

Надеюсь, что вы вдоволь насладились этой статьей. Если хотите оставаться в курсе интересных событий, читайте меня в Твиттере. Остались вопросы? Комментарии приветствуются! Для меня важно получить ваш отклик, чтобы самому еще лучше понять все эти концепции.

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

--

--

--

Русский военный корабль, иди нахуй! | Lead Software Engineer at EPAM | Blogger

Love podcasts or audiobooks? Learn on the go with our new app.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Bohdan Balov 🇺🇦

Bohdan Balov 🇺🇦

Русский военный корабль, иди нахуй! | Lead Software Engineer at EPAM | Blogger

More from Medium

cs371p Spring 2022 Week 4: Badr Belhiti

9 Policies For Security Procedures Examples

7 Key Takeaways from multiply by Francis Chan

How to write a formal e-mail course notes