Нейронные оптимизаторы запросов в реляционных БД (Часть 3): Погружение в ранжирование

от автора

Введение

Ранжирование — это уникальная разновидность задач в машинном обучении, обособленная как от классификации, так и регрессии. Заключительная статья по нейрооптимизаторам в РСУБД, как ни странно, связана именно с ней. Бум в развитии подобных моделей произошёл совсем недавно — в 2023 году, что мы с вами подробно разберём. Сначала погрузимся в ранжирование в целом, а затем увидим, как в соответствии с новой постановкой задачи адаптировались методы поиска оптимального плана исполнения запроса.

Создатели подхода LTR (Learning-To-Rank) предположили, что строить регрессионную ML-модель для предсказания стоимости выполнения плана запроса избыточно. По итогу всё сводится к выбору одного лучшего по оценке плана относительно других эквивалентных планов для заданного запроса. Т.е. на самом деле нам достаточно решить задачу ранжирования и, опираясь на признаковые описания планов, строить такую модель, которая начнёт предсказывать ранг (относительный порядок) для каждого плана для их дальнейшей сортировки и выбора наилучших. Преимущество здесь в том, что происходит упрощение модели и вместо аппроксимации сложной функции стоимости, которая оперирует масштабами абсолютных значений реального времени выполнения запросов, мы получаем простой ответ на следующий вопрос: «Лучше ли план A относительно плана B?».  Какая нам разница, выполняется план A 10 или 100000 миллисекунд, нам нужно знать лишь факт — лучше или хуже план A, чем B? Этот подход позаимствован из рекомендательных систем, в которых важен сам порядок товаров в выдаче, а не их оценка по выбранной шкале релевантности. Собственно с этого мы и начнём.

Задача ранжирования

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

Метрики ранжирования 

Перед разбором основных методик нам нужна база, от которой мы сможем отталкиваться. В качестве базы возьмём метрики оценки качества сортировки объектов, полученной в результате работы модели:

DCG@k (Discounted Cumulative Gain at k) — дисконтированная сумма релевантностей первых k элементов отсортированного списка

Что такое релевантность? Задать её можно по-разному, но обычно берут либо {0,1} — релевантен/нерелевантен, либо целочисленное значение от 0 до какого-то L, где 0 — наименьшая релевантность, а L — наибольшая. Дисконтирование — стандартный приём, когда нам нужно обращать больше внимания на первые объекты, нежели на последние. В случае DCG@k дисконтирование вводится при помощи умножения на обратный логарифм позиции. Таким образом, получаем следующую формулу:

DCG@k=\sum_{i=1}^{k}\frac{rel_i}{\log_{2}({i+1})}

Ещё часто числитель модифицируют, чтобы больше внимания уделялось релевантным объектам:

DCG@k=\sum_{i=1}^{k}\frac{2^{rel_i}-1}{\log_{2}({i+1})}

В целом интуиция здесь понятна — чем выше DCG@k, тем выше релевантность первых k элементов выдачи. Однако это может быть произвольное положительное число, поэтому надо бы его как-то отнормировать. Здесь и появляется NDCG@k.

NDCG@k (Normalized DCG@k) — нормализованный

NDCG@k=\frac{DCG@k}{IDCG@k},

где IDCG@k — максимальное возможное значение DCG@k для имеющегося списка элементов.

P@k(q) (Precision at k) — точность по первым элементам отсортированного списка по запросу

Очень простая метрика, которая буквально выражает процент релевантных объектов в выдаче, т.е.:

Precision@k(q)=\frac{1}{k}\sum_{i=1}^{k}r_i,

где r_i=1, если i-й элемент релевантен для запроса q и наоборот — r_i=0 для нерелевантных элементов.

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

AP@k(q) (Average Precision at k) — усреднённая точность по первым элементам отсортированного списка по запросу

Усложнение вводится так: каждому релевантному объекту в выдаче на позиции i мы будем назначать вес P@i(q):

AP@k(q)=\frac{1}{\sum_{i=1}^{k}r_i}\sum_{i=1}^{k}r_i*P@i(q)

Т.е., если i-й объект релевантен и предыдущие объекты тоже релевантны (P@i(q) высокий), то он будет вносить большой вклад в AP@k(q). Если же вдруг среди первых объектов будет много нерелевантных (P@i(q) низкий), то и вклад i-го объекта будет также низким. Таким образом, нам важно, чтобы вначале выдачи было как можно больше релевантных объектов, поскольку после каждого нерелевантного P@i(q) будет падать и тем самым тянуть вниз все оставшиеся элементы списка.

Пример: AP@3 для (1 1 0) > AP@3 для (1 0 1) > AP@3 для (0 1 1)
А P@k везде будет одинаковым и равным \frac{2}{3}

MAP (Mean AP) — среднее от усреднённых точностей по всем запросам

Название этой метрики звучит как тарабарщина — к сожалению, «mean» и «average» переводятся одинаково 🙂

Считается MAP буквально, как среднее от AP по каждому запросу:

MAP=\frac{1}{|Q|}\sum_{i=1}^{|Q|}AP(q_i),

где |Q| — количество всех запросов.

Как же научить модель сортировать множество заданных объектов по их относительной релевантности? Для этого существует несколько основных подходов:

Pointwise 

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

Pairwise 

Подход, работающий с парами объектов:

  1. В процессе обучения берётся два объекта из примера: x_i, x_j

  2. Для каждого объекта оценивается скор: f(w,x_i)=o_i и f(w,x_j)=o_j, где f(w,x_j) — результат работы модели f с тензором параметров w на объектах x_i, x_j соответственно

  3. Считаем разность o_{ij}=o_i-o_j

  4. Далее вводим логистическую функцию потерь и считаем, что \sigma(o_{ij})=\frac{1}{1+e^{-o_{ij}}} — это вероятность события x_i ≻ x_j, т.е. что объект x_i окажется выше, чем x_j

  5. Таким образом, всё свелось к бинарной классификации, которая может решаться огромным спектром параметризованных моделей, начиная логистической регрессией с бустингами и заканчивая нейросетями (RankNet/DirectRanker):

RankNet | LambdaRank | Tensorflow | Keras | Learning To Rank |  implementation | Medium

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

Динамика значений NDCG@5 и Pairwise-лосса

Динамика значений NDCG@5 и Pairwise-лосса

Listwise

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

А в чём собственно сложность? Почему бы сразу не брать, например, NDCG как функцию потерь, напрямую оптимизируя желаемую метрику? Дело в том, что NDCG не дифференцируема, поскольку предварительно требует сортировки всех элементов по релевантности для последующего расчёта коэффициента дисконтирования (операция сортировки как таковая не дифференцируема). Таким образом, необходимо придумывать ухищрения, чтобы включать метрику ранжирования в нашу loss-функцию, которую уже после можно будет оптимизировать градиентными методами.

LambdaRank

Одним из таких подходов является LambdaRank, который использует ту же методику обучения, что и рассмотренный ранее pairwise, однако в дополнение ко всему в нём оптимизируемый функционал для пары объектов x_i, x_j домножается на |{\Delta NDCG}_{ij}| — изменение метрики NDCG при перестановке пары объектов между собой:

L_{ij}=|{\Delta NDCG}_{ij}|*\log(1+e^{-o_{ij}}),

где истинный скор такой, что y_i>y_j.

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

SoftRank

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

S_j\sim N(s_j, \sigma^2)=N(f(w,x_j), \sigma^2),

где стандартное отклонение \sigma — это просто гиперпараметр, а f(w,x_j) — результат работы модели f с тензором параметров w на объекте x_j.

Теперь мы можем посчитать вероятность \pi_{ij} того, что объект i окажется выше в ранжировании объекта j:

\pi_{ij}=P(S_i>S_j)=P(S_i-S_j>0)=\int_{0}^{\infty}N(s|s_i-s_j, \sigma^2)ds

И, наконец, магический шаг, после которого получим дифференцируемый аналог NDCG — необходимо оценить вероятность того, что объект j будет иметь ранг r после добавления элемента под номером i=1..N к уже имеющемуся списку. Сделаем это рекурсивно:

  1. Очевидно, что когда i=1, то p_j^{(1)}(r)=\begin{cases} 1 & \quad \text{если $r=0$}\\ 0 & \quad \text{если $r\neq0$}\end{cases}, т.е. вероятность
    получить лучший ранг для j-го элемента равна единице.

  2. Пусть у нас имеется i-1 элементов списка и добавляется i-й. Тогда по формуле полной вероятности имеем следующее соотношение:

    p_j^{(i)}(r)=p_j^{(i-1)}(r-1)*\pi_{ij}+p_j^{(i-1)}(r)*(1-\pi_{ij})

    Формула получилось громоздкой, но не сильно сложной. Разберём её по частям:

    1. Во-первых, возможен сценарий \pi_{ij}, что новый i-й элемент встанет выше текущего элемента j — в этом случае, чтобы получить ранг r, элементу j необходимо до добавления элемента i находиться на позиции r-1, т.к. он поднимется в списке и (r-1)+1=r. Вероятность, что j-й будет на позиции r-1 в списке из i-1 элементов нам уже известна: p_j^{(i-1)}(r-1). Итоговая вероятность в нашем случае — это произведение p_j^{(i-1)}(r-1)*\pi_{ij}, которое и стоит первым в правой части разбираемого уравнения.

    2. Во-вторых, возможен противоположный сценарий 1-\pi_{ij}, что новый i-й элемент встанет ниже текущего элемента j — в этом случае, чтобы получить ранг r, элементу j необходимо до добавления элемента i находиться на позиции r, т.к. он останется на своей же позиции без изменений. Вероятность, что j-й будет на позиции r в списке из i-1 элементов нам тоже известна: p_j^{(i-1)}(r). В итоге получаем вероятность p_j^{(i-1)}(r)*\pi_{ij}, которая стоит второй в правой части разбираемого уравнения. 

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

Слева дискретное распределение скора и рангов, а справа — вероятностное

Слева дискретное распределение скора и рангов, а справа — вероятностное

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

SoftNDCG=\frac{1}{IDCG}\sum_{j=1}^{N}\frac{2^{rel_i}-1}{\sum_{r=0}^{N-1}\log_{2}({r+2})*p_j(r)}

Можно это выражение переписать и в обобщённом виде, как обычно принято делать:

SoftNDCG=\frac{1}{IDCG}\sum_{j=1}^{N}g(j)\sum_{r=0}^{N-1}D(r)*p_j(r),

где g(j) — скор объекта, а D(r) — введённый метод дисконтирования.

Понятное дело, что SoftNDCG теперь можно прикручивать к любой параметризированной ML-модели, как уже было показано в случае pairwise-подходов и задачи бинарной классификации. Ещё замечу, что для получения listwise-функции потерь, нужно не забыть в алгоритме градиентного спуска использовать SoftNDCG со знаком минус, поскольку минимизация -SoftNDCG даст максимизацию самого SoftNDCG, а, следовательно, максимизацию NDCG, что нам и нужно.

Посмотрим теперь на зависимость NDCG от применения выведенных Listwise-функций потерь:

Динамика значений NDCG@5 и Liswise-лосса

Динамика значений NDCG@5 и Liswise-лосса

В отличие от pairwise-подхода на графике заметна прямая зависимость метрики ранжирования от значения лосс-функции.

LambdaLoss

LambdaLoss — ещё один подход, который обобщает LambdaRank, вводя вероятностное распределение над всеми возможными перестановками объектов в списке относительно предсказанного для них скора:

P(y,f(x))=\sum_{\pi\in\Pi}P(y|f(x),\pi)*P(\pi|f(x)),

где P(y,f(x)) — вероятность получить метки релевантности y с учётом полученных из модели скоров f(x) и перестановки \pi из множества всех перестановок \Pi.

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

l(y, f(x))=-\sum_{\pi\in\Pi}P(y|f(x),\pi)*P(\pi|f(x))

Путём подстановки соответствующих вероятностей P(y|f(x),\pi) и P(\pi|f(x)) можно получать разный лосс для разных моделей, включая RankNet-loss, SoftRank-loss, LambdaRank-loss и, наконец, LambdaLoss. Затем вводится итеративный EM-алгоритм для поиска наилучшей модели f(x):

f^{(i+1)}=argmin\frac{1}{N}\sum_{(x,y)\in T}l_C(y,f(x)),l_C(y,f(x))=-\sum_{\pi\in\Pi}P^{i}(\pi|f(x))\log_{2}P(y|f(x),\pi)

где l_C(y,f(x)) — выражение, полученное применением неравенства Йенсена.

Давайте теперь посмотрим, как в этой форме можно представить loss, связанный с метрикой NDCG:

Для начала авторы определяют NDCG, как:

NDCG=\sum_{i=1}^N\frac{G_i}{D_i},

где G_i=\frac{2^{y_i-1}-1}{INDCG} — нормированная релевантность элемента, а D_i=\log{2}(i+1) — чисто технически тот же самый коэффициент дисконтирования, только вынесенный в знаменатель.

Далее вводится в некотором смысле обратная функция — NDCGCost:

NDCGCost=\sum_{i=1}^N\frac{G_i*(D_i-1)}{D_i}

Таким образом, чем больше NDCG, тем меньше NDCGCost. Теперь производится её верхняя оценка:

NDCGCost=\sum_{i=1}^N\frac{G_i*(D_i-1)}{D_i}\leq\sum_{i=1}^N\frac{G_i*(i-1)}{D_i}\leq\sum_{i=1}^N\frac{G_i}{D_i}\sum_{j=1}^{N}I_{s_i<s_j}\leq\leq\sum_{i=1}^N\sum_{j=1}^{N}\frac{G_i}{D_i}\log_{2}(1+e^{-\sigma(s_i-s_j)})=-\sum_{i=1}^N\sum_{j=1}^{N}\log_{2}(\frac{1}{1+e^{-\sigma(s_i-s_j)}})^{\frac{G_i}{D_i}}

где \sigma — гиперпараметр, I_{s_i<s_j} — пороговый loss, равный единице в случае, когда s_i<s_j и нулю во всех остальных. Пороговый loss аппроксимируется сверху гладкой логистической функцией, что и показано в выведенной выше оценке, графически же это выглядит так:

График верхней гладкой аппроксимации пороговой функции потерь

График верхней гладкой аппроксимации пороговой функции потерь

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

l_C(y,f(x))=-\sum_{i=1}^N\sum_{j=1}^{N}\log_{2}\sum_{\pi\in\Pi}(\frac{1}{1+e^{-\sigma(s_i-s_j)}})^{\frac{G_i}{D_i}}*H(\pi|f(x))H(\pi|f(x))=\begin{cases} 1 & \quad \text{если $\pi=\hat{\pi}$}\\ 0 & \quad \text{если $\pi\neq\hat{\pi}$}\end{cases},

где P(\pi|f(x))=H(\pi|f(x)) — так называемое «hard assignment distribution», не равное нулю только для перестановки \hat{\pi}, элементы в которой отсортированы в убывающем порядке по скорам f(x).

И остался последний чисто технический шаг — авторы преобразуют полученное уравнение в более гибкий вид:

l_C(y,f(x))=-\sum_{y_i>y_j}\log_{2}\sum_{\pi\in\Pi}(\frac{1}{1+e^{-\sigma(s_i-s_j)}})^{\delta_{ij}|G_i-G_j|}*H(\pi|f(x))\delta_{ij}=|\frac{1}{D_{|i-j|}}-\frac{1}{D_{|i-j|+1}}|

На этом с ранжированием закончим — именно в такой форме LambdaLoss используется в рассматриваемом LTR-оптимизаторе, к разбору которого мы сейчас и приступим.

Схема LTR-подхода

По сравнению с выводом LambdaLoss суть самого пайплайна довольно проста:

Схема LTR-подхода

Схема LTR-подхода

Сначала на вход поступает SQL-запрос, который затем попадает в цикл построения плана при помощи модификации стандартного оптимизатора. Модификация заключается во встраивании LTR-модели в процесс сравнения эквивалентных планов между собой. Но обо всё по порядку — начнём с того, как в принципе сформировать датасет для обучения модели задаче ранжирования:

Формирование датасета

Может показаться, что разметить планы перед обучением ранжированию достаточно просто — сортируем планы по времени выполнения и получаем их ранг в виде соответствующих позиций элементов списка. Однако в одних случаях разница в 5 миллисекунд может быть несущественной (например, в OLAP-запросах), а в других колоссальной (например, в OLTP-запросах). Поэтому для первого случая мы бы хотели назначить элементам одинаковый ранг, а во втором — разный. Для решения этой проблемы авторы применяют иерархическую кластеризацию, отсекая выбросы по времени и задавая количество кластеров в качестве регулируемого гиперпараметра:

Пример работы иерархической кластеризации

Пример работы иерархической кластеризации

Затем планы распределяются по кластерам, которые в дальнейшем сортируются по минимальному времени выполнения плана в рамках каждого кластера. По итогу все планы, входящие в i-й кластер, получат score=i.

Кодирование признаков

Аналогично Bao, в дереве плана кодируются только физические операторы при помощи one-hot, что позволяет модели не зависеть от конкретных таблиц в базе данных:

Кодирование плана в виде дерева векторов

Кодирование плана в виде дерева векторов

Для векторизации самого запроса используются 2 one-hot значения, обозначающие наличие операций ORDER BY и GROUP BY, а также 4 числовых признака: количество join’ов, оцененное количество строк в итоговом результате, максимальное и минимальное количество строк во всех таблицах, участвующих в запросе.

Кодирование SQL-запроса в виде вектора

Кодирование SQL-запроса в виде вектора

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

Инференс ранжирования

После шага с кодированием всё поступает на вход, можно сказать, несменяемым графовым свёрткам, тенденцию к использованию которых ввёл Neo:

Схема работы LTR-модели

Схема работы LTR-модели

В самом верху схемы Sub-Model 1 выделяет признаки из закодированного вектора запроса. Затем эти признаки присоединяются к дереву плана аналогично Neo.

Видим, что текущий план в схеме рассматривается отдельно и поступает в свой собственный расширенный пайплайн Sub-Model 2 для выделения признаков. Остальные эквивалентные планы идут на вход одной и той же Sub-Model 3, урезанной версии Sub-Model 2 для формирования усреднённого вектора контекста, который затем объединяется с вектором текущего плана и, проходя через Sub-Model 4, редуцируется до одного единственного числа — скора текущего плана. Проделав это вычисление для каждого плана из списка, получаем скоры, которые на трейне используются для оптимизации LambdaLoss’а, а на инференсе — для выбора самых быстрых планов из списка эквивалентных.

Остаётся последний вопрос — как в принципе встроить такой нетипичный LTR-подход в существующий пайплайн поиска плана? Ведь большая часть алгоритмов основана на попарном сравнении двух планов и выборе наилучшего. Вследствие этого авторам пришлось модифицировать существующий bottom-up алгоритм DPccp. Изначально в нём происходит последовательное наращивание подграфов с использованием pairwise-сравнений, приводящих к построению оптимального графа выполнения запроса. Модификация же предполагает не огромное количество попарных сравнений всех возможных вариаций эквивалентных планов, а их первоначальное накопление до некоторого заданного количества k, а затем выбора топ-k наилучших в результате работы разобранного LTR. 

Результаты

Свою LTR-модель авторы сравнивают с 4 равными подходами:

  1. Bao

  2. Neo

  3. Baseline model — pairwise LTR модель, чья архитектура схожа с Bao

  4. DB X — по традиции неназванная хорошо настроенная коммерческая база данных

Всего в сущности было проведено 3 теста:

  1. На датасете TCP-H 1GB с тренировкой на нём же

  2. На датасете TCP-H 5GB с тренировкой на TCP-H 1GB

  3. На датасете IMDB/JOB с тренировкой на TCP-H 1GB

Третий тест в особенности интересен, поскольку покажет способность модели работать в произвольной БД без необходимости в дообучении.

Запросы для TCP-H генерировали с использованием DataFarm — инструмента для аугментации ограниченного набора запросов.

Посмотрим на результаты первых двух тестов:

Диаграмма тестов на TCP-H

Диаграмма тестов на TCP-H

В первом тесте (график слева) видно, что LTR, будучи обученной на той же БД с теми же данными, обгоняет нейросетевые подходы и сравнима с DB X.

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

В третьем тесте демонстрируется производительность моделей, обученных на TCP-H, но прогоняемых на IMDB/JOB. При этом отмечается, что в JOB присутствуют запросы, производящие вплоть до 11 join’ов, тогда как на обучении максимально было 7 join’ов — это покажет способность модели экстраполировать свои знания на общий случай произвольного числа join’ов. Картина получается следующая:

Диаграмма тестов на IMDB/JOB

Диаграмма тестов на IMDB/JOB

Neo здесь заочно проигрывает и не берётся в сравнение, поскольку он не адаптируется под изменение схемы БД. Bao почти везде уходит в таймаут, оно и не удивительно — Bao нужно периодически дообучать на новой нагрузке с новыми данными, чего по всей видимости не делалось. В конкурентах остаются только DB X и baseline pairwise-подход. По графику видно, что baseline pairwise везде либо сравним, либо проигрывает LTR-модели, следовательно, он проигрывает и в этом раунде. А вот с коммерческой БД уже не всё так однозначно — на быстрых запросах модель ранжирования либо проигрывает, либо сравнивается с DB X, однако на медленных обгон по скорости может быть более, чем в два раза. В любом случае ситуация для LTR отличная — она не только не посыпалась при смене БД вместе с рабочей нагрузкой, а выстояла и смогла состязаться с коммерческими системами.

Вывод

Рассмотренная LTR-модель и правда уникальна — она впитала в себя как существующие лучшие практики уже рассмотренных нейросетевых оптимизаторов (Часть 1Часть 2), так и массу других трудов из теории оптимизации, рекомендательных систем и классического ML. По итогу такой кропотливой работы был изобретён совершенно новый подход, результаты которого говорят сами за себя.

Позже появилось ещё несколько схожих алгоритмов, такие как LEON и COOOL, но рассмотренный в этой статье метод все-таки был первым. Конечно, нужно время, чтобы сделать окончательные выводы об эффективности и применимости этого класса подходов к решению задачи поиска оптимального плана выполнения запроса. Однако уже сейчас можно сказать, что переход к постановке задачи ранжирования выглядит более естественным и может дать свои плоды в коммерческих продуктах уже в ближайшее время.


ссылка на оригинал статьи https://habr.com/ru/articles/857998/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *