Как стать автором
Обновить

Обзор C++ библиотек глубокого обучения Apache.SINGA, tiny-dnn, OpenNN

Время на прочтение 14 мин
Количество просмотров 23K
Наслаждаясь созданием моделей в Питоне на замечательных Deep Learning фреймворках типа Keras или Lasagne, время от времени хочется посмотреть, а что там интересного появилось для C++ разработчиков, помимо мейнстримовых TensorFlow и Caffe. Я решил поближе посмотреть на трех представителей: tiny-dnn, Apache.SINGA и OpenNN. Краткое описание опыта установки, сборки и использования под Windows Вы и найдете под катом.

Модельная задача бинарной классификации цепочек слов


Сравнение C++ библиотек глубого обучения я сделал в рамках экспериментов с разными способами представления слов в упрощенной задаче построения так называемой language model. Подробное описание всех вариантов выходит за рамки данной публикации, так что сформулирую задачу кратко.

Есть n-граммы (цепочки слов) заранее выбранной одинаковой длины, в данном случае 3 слова. Если n-грамма получена из текстового корпуса, то считаем, что это валидное сочетание слов и модель должна выдать целевое значение y=1. Обратите внимание, что n-граммы извлекаются из корпуса без учета синтаксической структуры предложения, поэтому 3-грамма "сел котик на" будет корректна, хотя граница справа прошла между предлогом и его объектом. Если же N-грамма получена случайной заменой одного из слов и такая цепочка не встречается в корпусе, то от модели ожидается целевое значение y=0.

Недопустимые N-граммы генерируются в ходе анализа корпуса в том же количестве, что и валидные. Получается идеально сбалансированный датасет, что облегчает задачу. А отсутствие необходимости ручной разметки позволяет легко «играться» таким параметром, как количество записей в обучающем наборе.

Таким образом, решается бинарная классификационная задача. Для экспериментов с C++ библиотеками я взял самый простой вариант представления слов через объединение word2vector представлений отдельных слов. Модель w2v обучалась на 70-Гб корпусе текстов с размером вектора 32. В итоге, каждая 3-грамма представлена вектором длины 96. Тренировочный набор состоит из 340,000 записей. Валидация и финальная оценка выполняется по отдельным наборам примерно такого же объема. Генерация файлов данных для C++ моделей делается скриптом на питоне, так что все сравниваемые библиотеки и модели гарантированно обучаются и валидируются на одних и тех же данных.

Решение задачи бинарной классификации на C++ с помощью tiny-dnn


Поиск на Хабре упоминаний библиотеки tiny-dnn дает одну ссылку на статью: habrahabr.ru/post/319436

Установка и подключение tiny-dnn к своему проекту чрезвычайно просты.

1. Клонируем себе содержимое репозитория.
2. Компиляция библиотеки не требуется, так как все реализовано в заголовочных файлах.
Для использования достаточно указать в своем коде директиву #include «tiny_dnn/tiny_dnn.h».

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

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

Исходный текст реализации простой feed forward сетки (MLP) на C++ и соответствующий
проект для VS 2015 лежит тут. Конструирование сетки заключается в вызове одной функции, которой указываются число входов, размер скрытого слоя и число выходов:

auto nn = make_mlp<sigmoid_layer>({ input_size, num_hidden_units, output_size });

Затем создается объект, выполняющий оптимизацию весов, парочка колбэк-функций для отслеживания процесса обучения и запускается обучение:

gradient_descent optimizer;

optimizer.alpha = 0.1;

auto on_enumerate_epoch = [&]() { /*...*/ };
auto on_enumerate_data = [&]() { /*...*/ };

nn.train<mse>(optimizer, X_train, y_train, batch_size, nb_epochs, on_enumerate_data,			on_enumerate_epoch);


Немного режет глаз, что обучающие и проверочные данные, передаваемые в процедуру обучения, объявляются вот так:

std::vector<vec_t> X_train = ...;
std::vector<label_t> y_train = ...;

При этом:

typedef std::vector<float_t, aligned_allocator<float_t, 64>> vec_t;

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

Построенная нейросетка, в которой активация всех слоев — сигмоида, дает точность примерно 0.58. Это намного хуже значений, которые можно получить в модели на Keras или Apache.SINGA. Допускаю, что сменив активацию выходного слоя на softmax, активацию промежуточных слоев на relu и поиграв с настройками оптимизатора, можно улучшить результат.

Вообще говоря, в данной библиотеке есть все основные архитектурные решения, необходимые для экспериментов с deep learning моделями. В частности, имеются сверточные слои, слои для пулинга, рекуррентные слои, а также регуляризаторы — dropout и batch normalization. Видны также средства для организации более сложных вычислительных графов, например слой для объединения.

Но все-таки, библиотека требует добавления функционала (возможно, он есть, а я его не заметил) как минимум в плане реализации early stopping, model checkpoint, так как без таких базовых возможностей использовать ее на серьезных задачах трудно. Вы можете увидеть, что передаваемая в метод tiny_dnn::network<...>::train<...> колбэк-функция on_enumerate_epoch возвращает void. Было бы разумным сделать ее возвращающей bool. Тогда пользовательский код мог бы передавать сигнал выхода из цикла обучения по эпохам и самостоятельно реализовать свой критерий ранней остановки, предотвращая получение переобученной модели.

Решение задачи бинарной классификации на C++ с помощью Apache.SINGA


Почему мне нравится Apache.SINGA?


Во-первых, у них симпатичный логотип. Котик-маскот — такая милота не может быть плохим проектом!

Во-вторых, просмотр примеров создания сеток на C++ средствами этой библиотеки
(https://github.com/apache/incubator-singa/blob/master/examples/cifar10/alexnet.cc) демонстрирует идеологическую близость к описанию сеточных моделей, например, в Keras. Проскакивает мысль — да тут почти как у людей, в смысле у питонистов.

Спойлер
Ну не совсем как у людей, все таки это C++ style.

Документация позиционирует SINGA как библиотеку с поддержкой распределенной обработки больших сеточных моделей и реализацией основных deep learning архитектур, включая сверточные и рекуррентные слои. Декларируется поддержка расчетов на GPU через CUDA и OpenCL, а это очень серьезная заявка на продакшн с тяжелыми моделями. Впрочем, я проверял только CPU-вариант, поскольку под Windows у меня нет GPU, а под Ubuntu повторять весь цикл сборки я пока не готов (см. далее).

На Хабре нашлось одно упоминание Apache.SINGA годичной давности в виде пары абзацев, так что более подробное описание с советами по установке и с примерами, думаю, будет кому-нибудь полезным.

Приступим к установке и испытаниям.

Установка Apache.SINGA из исходников под Windows


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

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

Теперь по шагам.

0. Инструментарий для сборки.

0.1 Потребуется CMake для генерации студийных sln (https://cmake.org/download/)
0.2 Нужен клиент git для скачивания исходников.
0.3 Я использую VisualStudio 2015, как компилировать все компоненты другими версиями студии я не проверял.
0.4 Для сборки OpenBLAS, судя по всему, нужен Perl, я поставил <a href="https://www.perl.org/get.html">ActivePerl и не заметил проблем.

1. Установка Protobuf.

1.1 Скачиваем исходники Protobuf под Windows.

1.2 Следуем инструкциям по сборке. В частности, генерация sln файла для VS 2015 x64 выглядит так:

cmake -G "Visual Studio 14 2015 Win64" ^
-DCMAKE_INSTALL_PREFIX=../../../install ^
-Dprotobuf_BUILD_SHARED_LIBS=ON ^
../..

Особо обращаю внимание на опцию -Dprotobuf_BUILD_SHARED_LIBS=ON. Об этом не сказано в
сборочной документации для SINGA, но где-то в ее исходниках гвоздями прибито использование
dll-вариантов зависимостей. Аналогичный подход нужен для GLOG и CBLAS.

1.3 Компилируем нужную конфигурацию — Release или Debug. Я делал это в VS, используя сгенерированный cmake'ом файл sln. Пока компилируется, слушаем эмбиент, пьем чай, читаем Хабр.

2. Установка OpenBLAS.

2.1 Скачиваем исходники опенбласа куда-нибудь себе:

git clone https://github.com/xianyi/OpenBLAS.git

2.2 Готовим sln с помощью CMake, у меня для VS2015 x64 была такая команда:

cmake -G "Visual Studio 14 2015 Win64"

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

3. Установка glog.

В исходниках SINGA виден вариант сборки без использования GLOG, с какой-то упрощенной
версией логгера, но под Windows тот вариант не соберется.

3.1 Скачиваем:

git clone https://github.com/google/glog.git

3.2 Делаем

mkdir build & cd build

Затем

cmake -G "Visual Studio 14 2015 Win64" -DBUILD_SHARED_LIBS=1 ..

Про смысл -DBUILD_SHARED_LIBS=1 я говорил ранее в описании сборки Protobuf.

Не обращаем внимание на кучу «not found» в консоли, а затем запускаем студию:

glog.sln

Выбираем нужную конфигурацию (Release или Debug), компилируем. Пьем чай.

3. Сборка SINGA

3.1 Выписываем пути к библиотекам и хидерам из предыдущих шагов, собираем примерно
такую порцию команд для консоли:

set CBLAS_H="e:\polygon\SINGA\cblas\OpenBLAS" 
set CBLAS_LIB="e:\polygon\SINGA\cblas\OpenBLAS\lib\RELEASE\libopenblas.lib"
set PROTOBUF_H=e:\polygon\SINGA\protobuf\protobuf-3.3.0\src
set PROTOBUF_LIB=e:\polygon\SINGA\protobuf\protobuf-3.3.0\cmake\build\solution\Release\
set PROTOC_EXE=e:\polygon\SINGA\protobuf\protobuf-3.3.0\cmake\build\solution\Release\protoc.exe
set GLOG_H=e:\polygon\SINGA\glog\src\windows\
set GLOG_LIB=e:\polygon\SINGA\glog\build\Release\glog.lib ..

Этот этап самый сложный, так как ни у кого не получится сразу догадаться, какие пути к
заголовочным файлам надо указать. Я смог угадать это с 4-ой или 5-ой попытки, так что не
бросайте не полпути, все получится.

cmake -G "Visual Studio 14 2015 Win64" -DUSE_CUDA=OFF -DUSE_PYTHON=OFF ^
 -DCBLAS_INCLUDE_DIR=%CBLAS_H% ^
 -DCBLAS_LIBRARIES=%CBLAS_LIB% ^
 -DProtobuf_INCLUDE_DIR=%PROTOBUF_H% ^
 -DProtobuf_LIBRARIES=%PROTOBUF_LIB% ^
 -DProtobuf_PROTOC_EXECUTABLE=%PROTOC_EXE% ^
 -DGLOG_INCLUDE_DIR=%GLOG_H% ^
 -DGLOG_LIBRARIES=%GLOG_LIB%
 -DUSE_GLOG

Получим студийный singa.sln. Запускаем студию.

Тут потребуется еще крошечная модификация проекта. Я не нашел способа указать необходимые define'ы в параметрах запуска cmake, поэтому просто в студийном проекте в разделе C++/Preprocessor указал:

USE_GLOG
PROTOBUF_USE_DLLS

Начинаем компиляцию. Чай уже не влезет, просто релаксируем.

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

В итоге, в папке build\lib\Release\ появляется заветный файлик singa.lib

Коллега, если ты добрался до этого места, то поздравляю тебя с успешным завершением
первого этапа миссии! Ты скорее всего собрал неработоспособную библиотеку singa.lib, сейчас объясню как это проверить и что делать далее.

Проверяем работоспособность singa.lib


Для быстрой проверки надо собрать такую программку:

#include "singa/utils/channel.h"
int main(int argc, char **argv)
{
 singa::InitChannel(nullptr);
 std::vector<std::string> rl = singa::GetRegisteredLayers();
 // смотрим содержимое rl
 
 return 0;
}

Если функция singa::GetRegisteredLayers() вернула пустой список, значит проблема присутствует
и ни одна нейросетка далее не будет работать.

С помощью отладчика можно увидеть следующую картину. Библиотека реализует несколько
фабричных классов, которые по строковому названию слоя типа «singacpp_dense» возвращают
объект соответствующего класса. Чтобы инициализировать фабрики, авторы библиотеки
написали макрос

#define RegisterLayerClass(Name, SubLayer) \
  static Registra<Layer, SubLayer> Name##SubLayer(#Name);

Каждый класс для фабрики создается через объявление глобального статического объекта:

RegisterLayerClass(singa_dense, Dense);
RegisterLayerClass(singacpp_dense, Dense);
RegisterLayerClass(singacuda_dense, Dense);
RegisterLayerClass(singacl_dense, Dense);

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

И вот тут уже надо насторожиться. C++ вообще реализует принцип максимального удивления (привет Питону): конструкция с неопределенным поведением всегда ведет себя неожиданно и стреляет в ногу. В частности, опыт говорит, что следует избегать использования статических конструкторов в библиотеках C++, так как они могут вообще не работать (в static library), либо вызываться не в том порядке, в котором ожидает разработчик, в общем вызывать много разных безобразий. Лучше написать функцию, явно вызывающую нужные конструкторы в нужном порядке и дергать ее в клиентском коде, чем разбираться с непортируемыми нюансами компиляторов под Windows и Linux.

В общем, я добавил в SINGA отдельную функцию:

namespace singa
{
	void initialize_static_ctors()
	{
		RegisterLayerClass(singa_dense, Dense);
		RegisterLayerClass(singacpp_dense, Dense);
		//RegisterLayerClass(singacuda_dense, Dense);
		//RegisterLayerClass(singacl_dense, Dense);

		RegisterLayerClass(singa_relu, Activation);
		RegisterLayerClass(singa_sigmoid, Activation);
		RegisterLayerClass(singa_tanh, Activation);

		RegisterLayerClass(singacpp_relu, Activation);
		//RegisterLayerClass(singacuda_relu, Activation);
		//RegisterLayerClass(singacl_relu, Activation);
		RegisterLayerClass(singacpp_sigmoid, Activation);
		//RegisterLayerClass(singacuda_sigmoid, Activation);
		//RegisterLayerClass(singacl_sigmoid, Activation);
		RegisterLayerClass(singacpp_tanh, Activation);
		//RegisterLayerClass(singacuda_tanh, Activation);
		//RegisterLayerClass(singacl_tanh, Activation);

		return;
	}
}

С ее помощью все заработало как надо — фабрики проинициализировались, сетка стала работать.
Если кто-то дрогнет и не захочет заниматься сборкой бинарников SINGA, то я выложил скомпилированные под Win x64 библиотеки в репозиторий.

Реализация простой feed forward нейросетки


Беглый анализ исходников показывает, что Apache.SINGA поддерживает полносвязные слои (dense), сверточные слои (convolution), пулинг (pooling), варианты рекуррентных архитектур — RNN, GRU, LSTM и двунаправленная LSTM. Варианты активационной функции — минимальный набор из tanh, sigmoid и relu. В общем, весь джентльменский набор deep learning в наличии.

Сразу предупреждаю привыкших к питоновскому сервису: во многих случаях неправильное
использование функциональных возможностей SINGA ведет к memory fault.

Неправильно выбрал тип данных для тензора y? Получи memory fault.

Задал 1 выход вместо 2 на финальном слое для бинарной классификации? Держи memory fault.

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

В общем, многие вещи в SINGA надо прочуствовать, так как заранее догадаться о них не всегда можно. Как я уже сказал, тензор для целевой переменной y для обучения и валидации должен обязательно иметь тип данных kInt. Попытка создавать его как и тензор X с типом kFloat32 приведет к ошибке сегментации, поскольку в одном участке кода делается каст void* указателя к int*, и если там на самом деле указатель на float, то получаем ошибку.

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

static FeedForwardNet create_net(size_t input_size)
{
	FeedForwardNet net;
	Shape s{ input_size };

	net.Add(GenHiddenDenseConf("dense1", 96, 1.0, 1.0), &s);
	net.Add(GenSigmoidConf("dense1_a"));
	net.Add(GenOutputDenseConf("dense_output", 2, 1.0, 1.0));
	net.Add(GenSigmoidConf("dense2_a"));

	return net;
}

Если в силу привычки работы с Keras забыть указать активацию, рассчитывая получить sigmoid, то будет сюрприз, о котором Вы догадаетесь по странной кривой обучения: полносвязный слой (dense) будет просто линейным.

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

Обучение нейросети, как обычно, сводится к подстройке весов и смещений для минимизации функции потерь. Организацией процесса подстройки занимается оптимизатор (отчасти, есть еще Updater), который Вы указываете при создании модели. В библиотеке доступны следующие варианты оптимизаторов (производные классы от Optimizer):

SGD
Nesterov
AdaGrad
RMSProp

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

	SGD sgd;
	OptimizerConf opt_conf;
	opt_conf.set_momentum(0.9);
	sgd.Setup(opt_conf);

Наконец, «компилируем» сетку, указывая функцию потерь и метрику. Этот этап тоже знаком тем, кто использует DL библиотеки с питоном:

	SoftmaxCrossEntropy loss;
	Accuracy acc;
	net.Compile(true, &sgd, &loss, &acc);

Все готово — запускаем обучение, указав тензоры, число эпох и размер батча:

	size_t batch_size = 128;
	net.Train(batch_size, num_epoch, train_x, train_y, val_x, val_y);

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

Также есть регуляризаторы: dropout и batch normalization.

В принципе, всего вышеупомянутого достаточно для оценочных экспериментов и знакомства с этой замечательной библиотекой. Если посмотреть на содержимое папки примеров, то там есть достаточно сложные модели, написанные на питоновском враппере, например Char-RNN или классификатор изображений на базе GoogleNet. Мой эксперимент с бинарной классификацией, кстати, дает оценку точности в районе 0.745. Это несколько хуже, чем 0.80 для варианта нейросетки на Keras с Theano backend, но не настолько, чтобы бить тревогу, тем более что дополнительный тюнинг параметров и архитектуры может улучшить модель.

Субъективные оценки Apache.SINGA


(+) Представлены все основные сеточные архитектуры, включая сверточные и рекуррентные.
(+) Поддержка GPU.
(+) Расчеты на float.
(+) Более-менее привычный workflow при конструировании модели, понятная терминология.
(-) Сложная установка и компиляция.
(-) Не всегда интуитивные требования к выбору параметров и вариантов, проявляющиеся через memory faults.

Решение задачи бинарной классификации на C++ с помощью OpenNN


Это последняя библиотека, которую пробовал в рамках данного эксперимента. Описание в репозитории позиционирует библиотеку как высокопроизводельную реализацию нейросетей. Насколько достигнута данная цель не берусь сказать, но обучение моей реализации нейросетки запустилось на одном ядре CPU (Apache.SINGA, например, села на оба доступных ядра без дополнительных пинков с моей стороны), а представление данных с точностью double еще более смущает.

Тем не менее, рекомендую установить и попробовать эту библиотеку, благо это крайне простой процесс.

Сборка OpenNN


Процесс описан в сборочной документации, ничего сложного там нет.

1. Скачиваем:

git clone https://github.com/Artelnics/OpenNN.git

2. Создаем sln файл для VS 2015:

mkdir build & cd build
cmake -G "Visual Studio 14 2015 Win64" ..

3. Открываем созданный файл OpenNN.sln в Студии, компилируем. Это займет 10-15 минут. В подкаталоге ...\build\opennn\Debug (или Release) появится файл статической библиотеки opennn.lib.

На этом сборка заканчивается.

Использование OpenNN


Основная проблема с этой библиотекой — порог вхождения из-за непривычной терминологии и общей организации работы с моделью. Названия классов зачастую неинтуитивны, трудно догадаться об их назначении. Например, чем занимается класс LossIndex? Эта штука явно имеет
отношение к функции потерь, но почему «Index»?

Или параметры обучения. При запуске обучения нейросетки с дефолтными параметрами
в консоли видно сообщение, что выполняется квази-ньютоновская оптимизация — замечательно, но хотелось бы что-то вроде stochastic gradient descend. Переключение оптимизатора делается так:

training_strategy.set_main_type( OpenNN::TrainingStrategy::GRADIENT_DESCENT);

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

Задание максимального количества итераций и других параметров выглядит так:

TrainingStrategy training_strategy;
// ...
training_strategy.get_gradient_descent_pointer()->set_maximum_iterations_number(100);
training_strategy.get_gradient_descent_pointer()->set_maximum_time(3600);
training_strategy.get_gradient_descent_pointer()->set_display_period(1);

Как видите, названия классов и методов немного громоздки.

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

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

Есть и плюсы. В библиотеке свой класс для операций с датасетами, с незамысловатым названием DataSet. Он умеет загружать данные из csv файла, и даже понимает заголовок с названиями столбцов. С другой стороны, этот класс организован так, что грузит из одного файла и входные переменные X, и целевые значения y. Для меня это было сюрпризом, так что пришлось дописывать отдельный вариант сохранения датасета в питоновском скрипте специально для OpenNN.

В целом, написание своей реализации нейросетки для решения моей классификационной задачи
средствами OpenNN заняло немного больше времени, чем на SINGA или tiny-dnn. Насколько прозрачно получилось — можете оценить сами.

Субъективная оценка OpenNN


(+) простая установка и компиляция, зависимостей нет
(+) вроде есть поддержка CUDA
(±) свой класс для работы с датасетами DataSet умеет загружаться из CSV и знает
про заголовки, но требует, чтобы в одном файле были и X, и y.
(-) операции с double
(-) крайне необычная терминология, неинтуитивные названия классов, непривычный workflow при описании модели.

Продолжение с другими библиотеками


Как минимум, стоит посмотреть на майкрософтовский CNTK. Для этого фреймворка помимо стандартного набора архитектурных элементов deep learning в документации упоминается реализация reinforcement learning — технологического фундамента волны хайпа в последние пару лет.
Теги:
Хабы:
+17
Комментарии 10
Комментарии Комментарии 10

Публикации

Истории

Работа

Data Scientist
58 вакансий
Программист C++
128 вакансий
QT разработчик
15 вакансий

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн