Перед вами третий материал из серии статей, посвящённой настройке гиперпараметров. Если вы только осваиваете эту тему — взгляните на первую статью, в которой говорится о том, что такое настройка гиперпараметров. Во второй части, посвящённой настройке гиперпараметров в XGBoost, мы исследуем практический пример.В первом материале нашей серии, состоящей из трёх частей, мы говорили о том, как подбор гиперпараметров способен помочь в деле поиска оптимальных настроек, позволяющих получить наилучшие результаты от использования моделей машинного обучения. Затем, во втором материале, мы разобрались с тем, как проводить настройку гиперпараметров в XGBoost, и выяснили, что модель, гиперпараметры которой подверглись настройке, даёт более точные прогнозы, чем модель, гиперпараметры которой не модифицировались.

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

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

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

Портирование системы настройки гиперпараметров на Ray Tune

В рамках проекта Ray разработана библиотека tune-sklearn, которая рассчитана на прямую замену моделей настройки гиперпараметров из scikit-learn. Речь идёт о функциях GridSearchCV и RandomizedSearchCV, основанных, соответственно, на алгоритмах сеточного поиска и случайного поиска. Библиотека tune-sklearn, кроме того, поддерживает дополнительные модели подбора гиперпараметров — такие, как байесовский поиск, древовидные оценочные функции Парзена и другие. Выбор нужной модели сводится к установке входного параметра search_optimization в значение bayesian или bohb для использования байесовского поиска, или в значение hyperopt для применения древовидных оценочных функций Парзена.

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

Тут мы доработаем код, написанный в предыдущей статье. А именно — заменим функцию RandomizedSearchCV() на функцию из tune-sklearn.

Начнём с установки tune-sklearn. Выполним в командной строке следующую команду:

$ pip install tune-sklearn "ray[tune]"

Или воспользуемся такой командой:

$ pip install -U git+https://github.com/ray-project/tune-sklearn.git && pip install 'ray[tune]'

Теперь перепишем код, заменив RandomizedSearchCV на TuneSearchCV. Итоговая версия кода показана ниже, в комментариях даны пояснения по его модификациям:

import time
import numpy as np
import pandas as pd   # Для обработки данных, для организации ввода/вывода CSV-файлов (например - pd.read_csv).
import xgboost as xgb

from tune_sklearn import TuneSearchCV
from sklearn import metrics

# Функция для настройки гиперпараметров с использованием библиотеки tune-search.

def tune_search_tuning():

    # Файлы с входными данными находятся в папке "./data/".
    train_df = pd.read_csv("./data/mnist_train_final.csv")
    test_df = pd.read_csv("./data/mnist_test_final.csv")

    # Ограничим размер входного набора данных 1000 образцов. 
    dataset_size = 1000
    train_df = train_df.iloc[0:dataset_size, :]
    test_df = test_df.iloc[0:dataset_size, :]

    print("Reduced dataset size: ", train_df.shape)

    y_train = train_df.label.values
    x_train = train_df.drop('label', axis=1).values

    y_test = test_df.label.values
    x_test = test_df.drop('label', axis=1).values

    params = {'max_depth': [6, 10],
              'learning_rate': [0.1, 0.3, 0.4],
              'subsample': [0.6, 0.7, 0.8, 0.9, 1],
              'colsample_bytree': [0.6, 0.7, 0.8, 0.9, 1],
              'colsample_bylevel': [0.6, 0.7, 0.8, 0.9, 1],
              'n_estimators': [500, 1000],
              'num_class': [10]
              }

    start_time = time.time()
    print("starting at: ", start_time)

    # Объявим классификатор, использующий технологию градиентного бустинга, установив
    # параметр objective в значение "multi:softmax" и попытавшись ускорить работу путём
    # установки параметра tree_method в значение "hist".
    xgbclf = xgb.XGBClassifier(objective="multi:softmax",
                               tree_method="hist")

    # Заменим RandomizedSearchCV на TuneSearchCV.
    # Параметр n_trials задаёт количество выполняемых итераций
    # (разных комбинаций гиперпараметров).

    # В параметр verbose можно записывать числа от 0 до 3 (он задаёт то, насколько подробными будут отладочные сообщения).
    tune_search = TuneSearchCV(estimator=xgbclf,
                               param_distributions=params,
                               scoring='accuracy',
                               n_trials=20,
                               n_jobs=8,
                               verbose=2)

    # Выполним настройку гиперпараметров.
    tune_search.fit(x_train, y_train)

    stop_time = time.time()
    print("Stopping at :", stop_time)
    print("Total elapsed time: ", stop_time - start_time)

    best_combination = tune_search.best_params_

    # Оценка точности прогноза с использованием тестового набора данных.
    predictions = tune_search.predict(x_test)

    accuracy = metrics.accuracy_score(y_test, predictions)
    print("Accuracy: ", accuracy)

    return best_combination

if __name__ == '__main__':

    best_params = tune_search_tuning()
    print("Best parameters:", best_params)

Сохраним этот код в .py-файле. Например — дадим ему имя mnist_tune_search.py. Далее — выполним в консоли следующую команду:

$ python mnist_tune_search.py

Теперь Ray Tune запустит несколько процессов (воркеров) в параллельном режиме. Они будут использоваться для выполнения итераций кода обработки гиперпараметров. То, как именно будет выполняться код, зависит от архитектуры используемого компьютера (от количества процессоров и процессорных ядер).

Учитывая то, что параметр verbose установлен в значение 2, мы можем ожидать результатов работы кода, напоминающих следующие:

Reduced dataset size:  (1000, 785)
starting at:  1634516904.7016492
== Status ==
Memory usage on this node: 4.9/7.7 GiB
Using FIFO scheduling algorithm.
Resources requested: 2.0/12 CPUs, 0/0 GPUs, 0.0/2.54 GiB heap, 0.0/1.27 GiB objects
Result logdir: /home/juan/ray_results/_Trainable_2021-10-18_02-28-34
Number of trials: 16/20 (15 PENDING, 1 RUNNING)
.
.
.
Trial _Trainable_53eac_00019 reported average_test_score=0.85 with parameters={'early_stopping': False, 'early_stop_type': <EarlyStopping.NO_EARLY_STOP: 7>, 'X_id': ObjectRef(ffffffffffffffffffffffffffffffffffffffff0100000001000000), 'y_id': ObjectRef(ffffffffffffffffffffffffffffffffffffffff0100000002000000), 'groups': None, 'cv': StratifiedKFold(n_splits=5, random_state=None, shuffle=False), 'fit_params': {}, 'scoring': {'score': make_scorer(accuracy_score)}, 'max_iters': 1, 'return_train_score': False, 'n_jobs': 1, 'metric_name': 'average_test_score', 'max_depth': 10, 'learning_rate': 0.4, 'subsample': 1.0, 'colsample_bytree': 0.8, 'colsample_bylevel': 0.8, 'n_estimators': 1000, 'num_class': 10, 'estimator_ids': [ObjectRef(ffffffffffffffffffffffffffffffffffffffff0100000003000000)]}. This trial completed.
== Status ==
Memory usage on this node: 4.7/7.7 GiB
Using FIFO scheduling algorithm.
Resources requested: 0/12 CPUs, 0/0 GPUs, 0.0/2.54 GiB heap, 0.0/1.27 GiB objects
Current best trial: 53eac_00016 with average_test_score=0.869 and parameters={'max_depth': 10, 'learning_rate': 0.3, 'subsample': 0.9, 'colsample_bytree': 0.7, 'colsample_bylevel': 0.6, 'n_estimators': 500, 'num_class': 10}
Result logdir: /home/juan/ray_results/_Trainable_2021-10-18_02-28-34
Number of trials: 20/20 (20 TERMINATED)

+------------------------+------------+-------+---------------------+--------------------+-----------------+-------------+----------------+-------------+-------------+--------+------------------+---------------------+---------------------+---------------------+
| Trial name             | status     | loc   |   colsample_bylevel |   colsample_bytree |   learning_rate |   max_depth |   n_estimators |   num_class |   subsample |   iter |   total time (s) |   split0_test_score |   split1_test_score |   split2_test_score |
|------------------------+------------+-------+---------------------+--------------------+-----------------+-------------+----------------+-------------+-------------+--------+------------------+---------------------+---------------------+---------------------|
| _Trainable_53eac_00000 | TERMINATED |       |                 0.6 |                0.7 |             0.4 |           6 |            500 |          10 |         0.6 |      1 |          116.009 |               0.855 |               0.865 |               0.86  |
| _Trainable_53eac_00001 | TERMINATED |       |                 0.6 |                0.8 |             0.4 |          10 |            500 |          10 |         0.7 |      1 |          122.63  |               0.865 |               0.845 |               0.875 |
| _Trainable_53eac_00002 | TERMINATED |       |                 1   |                0.6 |             0.1 |           6 |           1000 |          10 |         0.7 |      1 |          300.112 |               0.87  |               0.865 |               0.885 |
| _Trainable_53eac_00003 | TERMINATED |       |                 1   |                1   |             0.4 |          10 |            500 |          10 |         0.6 |      1 |          132.811 |               0.87  |               0.875 |               0.85  |
| _Trainable_53eac_00004 | TERMINATED |       |                 0.9 |                0.6 |             0.4 |           6 |           1000 |          10 |         0.8 |      1 |          227.973 |               0.85  |               0.865 |               0.855 |
| _Trainable_53eac_00005 | TERMINATED |       |                 0.8 |                0.9 |             0.1 |          10 |           1000 |          10 |         0.6 |      1 |          310.683 |               0.85  |               0.88  |               0.875 |
| _Trainable_53eac_00006 | TERMINATED |       |                 0.8 |                0.9 |             0.3 |           6 |           1000 |          10 |         0.8 |      1 |          250.689 |               0.865 |               0.865 |               0.86  |
| _Trainable_53eac_00007 | TERMINATED |       |                 0.6 |                1   |             0.4 |           6 |            500 |          10 |         0.9 |      1 |          132.995 |               0.86  |               0.87  |               0.88  |
| _Trainable_53eac_00008 | TERMINATED |       |                 1   |                0.9 |             0.4 |           6 |            500 |          10 |         0.8 |      1 |          139.673 |               0.85  |               0.86  |               0.845 |
| _Trainable_53eac_00009 | TERMINATED |       |                 0.7 |                0.9 |             0.3 |           6 |            500 |          10 |         0.8 |      1 |          142.673 |               0.89  |               0.88  |               0.86  |
| _Trainable_53eac_00010 | TERMINATED |       |                 1   |                0.6 |             0.1 |           6 |            500 |          10 |         0.7 |      1 |          198.696 |               0.87  |               0.865 |               0.885 |
| _Trainable_53eac_00011 | TERMINATED |       |                 0.7 |                0.7 |             0.4 |          10 |            500 |          10 |         1   |      1 |          135.517 |               0.875 |               0.875 |               0.865 |
| _Trainable_53eac_00012 | TERMINATED |       |                 0.8 |                0.8 |             0.3 |           6 |            500 |          10 |         1   |      1 |          154.802 |               0.865 |               0.865 |               0.895 |
| _Trainable_53eac_00013 | TERMINATED |       |                 0.7 |                0.8 |             0.3 |           6 |            500 |          10 |         0.8 |      1 |          140.004 |               0.87  |               0.87  |               0.88  |
| _Trainable_53eac_00014 | TERMINATED |       |                 0.8 |                0.6 |             0.3 |          10 |           1000 |          10 |         0.8 |      1 |          232.962 |               0.85  |               0.87  |               0.89  |
| _Trainable_53eac_00015 | TERMINATED |       |                 0.7 |                1   |             0.1 |           6 |            500 |          10 |         0.8 |      1 |          216.729 |               0.86  |               0.895 |               0.87  |
| _Trainable_53eac_00016 | TERMINATED |       |                 0.6 |                0.7 |             0.3 |          10 |            500 |          10 |         0.9 |      1 |          136.364 |               0.87  |               0.88  |               0.88  |
| _Trainable_53eac_00017 | TERMINATED |       |                 0.6 |                1   |             0.4 |           6 |            500 |          10 |         0.7 |      1 |          123.14  |               0.86  |               0.855 |               0.855 |
| _Trainable_53eac_00018 | TERMINATED |       |                 0.9 |                0.8 |             0.3 |           6 |            500 |          10 |         0.6 |      1 |          133.816 |               0.84  |               0.865 |               0.875 |
| _Trainable_53eac_00019 | TERMINATED |       |                 0.8 |                0.8 |             0.4 |          10 |           1000 |          10 |         1   |      1 |          239.038 |               0.855 |               0.87  |               0.85  |
+------------------------+------------+-------+---------------------+--------------------+-----------------+-------------+----------------+-------------+-------------+--------+------------------+---------------------+---------------------+---------------------+

Stopping at : 1634517645.9786417
Total elapsed time:  741.276992559433
Accuracy:  0.848
Best parameters: {'max_depth': 10, 'learning_rate': 0.3, 'subsample': 0.9, 'colsample_bytree': 0.7, 'colsample_bylevel': 0.6, 'n_estimators': 500, 'num_class': 10}

Работа заняла чуть больше 12 минут, система нашла наилучшую комбинацию гиперпараметров, применение которой позволяет повысить точность прогноза до 84,8% (с 82,6%, которые дают значения гиперпараметров, используемые по умолчанию, рассмотренные в предыдущем материале).

Распределённый подбор наилучшей комбинации гиперпараметров

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

Кластер Ray
Кластер Ray

Теперь мы начнём с установки модуля Ray. Выполним в консоли следующую команду:

$ pip install ray

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

$ pip show ray 
Name: ray
Version: 1.7.0

$ pip show xgboost
Name: xgboost
Version: 1.2.0

Внесём в проект следующие изменения:

  • Скопируем файлы данных в одну и ту же директорию всех узлов кластера Ray. Используем абсолютный путь, например — /var/data.

  • Импортируем в код библиотеку Ray.

  • Инициализируем Ray так, чтобы подключиться к локальному узлу (ray.init(address='auto')).

  • Декорируем функцию tune_search_tuning() в виде задачи Ray, поставив  @ray.remote перед объявлением функции.

  • Приведём в актуальное состояние полные пути к файлам данных. Например — обновлённый путь может выглядеть так: /var/data/mnist_train_final.csv.

  • Добавим в функцию main код, отвечающий за создание воркеров и за работу с ними.

  • Переместим код подсчёта времени в функцию main.

Ниже показан обновлённый код с комментариями, поясняющими изменения:

import time
import numpy as np
import pandas as pd   # Для обработки данных, для организации ввода/вывода CSV-файлов (например - pd.read_csv).
import xgboost as xgb

from tune_sklearn import TuneSearchCV
from sklearn.model_selection import train_test_split
from sklearn import metrics
import ray

# Инициализируем исполняющую среду ray и прикрепим её к локальному экземпляру узла ray.
ray.init(address='auto')

# Функция для настройки гиперпараметров с использованием библиотеки tune-search.
# Добавим декоратор функции.
@ray.remote
def tune_search_tuning():

    # Файлы с входными данными находятся в папке "/var/data/".
    train_df = pd.read_csv("/var/data/mnist_train_final.csv")
    test_df = pd.read_csv("/var/data/mnist_test_final.csv")
    print (train_df.shape, test_df.shape)

    dataset_size = 1000
    train_df = train_df.iloc[0:dataset_size, :]
    test_df = test_df.iloc[0:dataset_size, :]

    y = train_df.label.values
    x = train_df.drop('label', axis=1).values

    y_test = test_df.label.values
    x_test = test_df.drop('label', axis=1).values

    # Объявим учебный и тестовый наборы данных.
    # В принципе, тестовые (правильные) данные позже не используются,
    # поэтому мы минимизируем размер набора, взяв лишь 5% от исходных данных.
    x_train, x_val, y_train, y_val = train_test_split(x, y, test_size=0.05)
    print("Shapes - X_train: ", x_train.shape, ", X_val: ", x_val.shape, ", y_train: ", y_train.shape, ", y_val: ", y_val.shape)

    # Массивы numpy не принимаются в качестве атрибутов параметров, 
    # поэтому мы, для создания списков, воспользуемся генератором списков
    params = {'max_depth': [3, 6, 10, 15],
              'learning_rate': [0.01, 0.1, 0.2, 0.3, 0.4],
              'subsample': [0.5 + x / 100 for x in range(10, 50, 10)],
              'colsample_bytree': [0.5 + x / 100 for x in range(10, 50, 10)],
              'colsample_bylevel': [0.5 + x / 100 for x in range(10, 50, 10)],
              'n_estimators': [100, 500, 1000],
              'num_class': [10]
              }

    # Объявим классификатор, использующий технологию градиентного бустинга, установив
    # параметр objective в значение "multi:softmax" и попытавшись ускорить работу путём
    # установки параметра tree_method в значение "hist".
    xgbclf = xgb.XGBClassifier(objective="multi:softmax",
                               tree_method="hist")

    # Заменим RandomizedSearchCV на TuneSearchCV.
    # Параметр n_trials задаёт количество выполняемых итераций
    # (разных комбинаций гиперпараметров).
    # В параметр verbose можно записывать числа от 0 до 3 (он задаёт то, насколько подробными будут отладочные сообщения).
    tune_search = TuneSearchCV(estimator=xgbclf,
                               param_distributions=params,
                               scoring='accuracy',
                               n_trials=25,
                               verbose=1)

    # Выполним настройку гиперпараметров.
    tune_search.fit(x_train, y_train)

    print("cv results: ", tune_search.cv_results_)

    best_combination = tune_search.best_params_
    print("Best parameters:", best_combination)

    # Оценка точности прогноза с использованием тестового набора данных.
    predictions = tune_search.predict(x_test)

    accuracy = metrics.accuracy_score(y_test, predictions)
    print("Accuracy: ", accuracy)

    return best_combination

if __name__ == '__main__':

    start_time = time.time()

    # Создание задачи.
    remote_clf = tune_search_tuning.remote()

    # Получение результатов выполнения задачи.
    best_params = ray.get(remote_clf)

    stop_time = time.time()
    print("Stopping at :", stop_time)
    print("Total elapsed time: ", stop_time - start_time)

    print("Best params from main function: ", best_params)

Сохраним этот код в файле mnist_ray_tune_distributed.py.

Прежде чем приступать к выполнению этого кода, нам надо запустить кластер Ray. Исчерпывающую документацию по этому вопросу можно найти здесь. Из неё можно узнать о том, как запустить кластер Ray на мощностях любого облачного провайдера. Там есть подробные сведения о популярных провайдерах вроде AWS, GCP и Azure. Кластер можно запустить и в локальном окружении, инициализируя головной узел и воркеры на различных серверах.

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

$ ray start --head --port=6379 
Local node IP: 192.168.0.196
2021-10-19 23:38:33,209	INFO services.py:1252 -- View the Ray dashboard at http://127.0.0.1:8265

--------------------
Ray runtime started.
--------------------

Next steps
  To connect to this Ray runtime from another node, run
    ray start --address='192.168.0.196:6379' --redis-password='5241590000000000'

  Alternatively, use the following Python code:
    import ray
    ray.init(address='auto', _redis_password='5241590000000000')

  To connect to this Ray runtime from outside of the cluster, for example to
  connect to a remote cluster from your laptop directly, use the following
  Python code:
    import ray
    ray.init(address='ray://<head_node_ip_address>:10001')

  If connection fails, check your firewall settings and network configuration.

Теперь, когда головной узел запущен, можно подключить узлы-воркеры с использованием пароля Redis, полученного после выполнения команды ray start --head. На остальных узлах кластера Ray нужно выполнить команду следующего вида, заменив представленный там IP-адрес на адрес головного сервера из актуального окружения:

$ ray start --address='192.168.0.196:6379' --redis-password='5241590000000000'

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

$ python mnist_ray_tune_distributed.py

Результат её выполнения будет выглядеть примерно так, как показано ниже:

INFO worker.py:827 -- Connecting to existing Ray cluster at address: 192.168.0.196:6379
.
. 
. 
Accuracy:  0.82
Total elapsed time:  315.5932471752167
Best params from main function:  {'max_depth': 3, 'learning_rate': 0.1, 'subsample': 0.9, 'colsample_bytree': 0.6, 'colsample_bylevel': 0.9, 'n_estimators': 1000, 'num_class': 10}

Это испытание завершилось за 315 секунд (примерно за 5 минут). Это — меньше, чем половина того времени, которое ушло на предыдущий эксперимент, завершившийся за 741 секунду, когда код запускался на единственном узле. Кроме того, теперь мы вышли на точность в 82%, обеспечиваемую наилучшей комбинацией гиперпараметров. Это близко к ранее полученным результатам (85,5%), когда использовалась функция RandomizedSearchCV из scikit-learn.

Распределённый подбор гиперпараметров на кластере работает гораздо быстрее, чем тогда, когда мы использовали единственный компьютер. Хотя мы получили практически такие же результаты, как при применении scikit-learn, эти результаты, благодаря применению распределённых вычислений, получены быстрее.

Что дальше?

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

В принципе, это — непростая задача. Обычно её решение требует серьёзного рефакторинга кода. Но благодаря инструментам, которые даёт нам Ray, преобразование обычного приложения в распределённое сводится к небольшим правкам существующего кода. Мы, воспользовавшись Ray Tune, быстро довели нашу систему распознавания рукописных цифр, рассмотренную в предыдущей статье, до оптимального уровня производительности.

Мы выяснили, что выполнение кода на кластере Ray и замена функции RandomizedSearchCV из scikit-learn на функцию TuneSearchCV из Ray Tune позволили нам распределить вычислительную нагрузку, связанную с подбором гиперпараметров, на несколько узлов. Это позволяет, в лучшем случае, так как на организацию распределённых вычислений уходят некоторые ресурсы, ускорить поиск гиперпараметров в N раз, где N — это количество узлов в кластере Ray.

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

О, а приходите к нам работать? ????

Мы в wunderfund.io занимаемся высокочастотной алготорговлей с 2014 года. Высокочастотная торговля — это непрерывное соревнование лучших программистов и математиков всего мира. Присоединившись к нам, вы станете частью этой увлекательной схватки.

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

Сейчас мы ищем плюсовиков, питонистов, дата-инженеров и мл-рисерчеров.

Присоединяйтесь к нашей команде.

Комментарии (0)