TD-лямбда в глубоком обучении с подкреплением

Метод N шагов

Здравствуйте и вновь добро пожаловать на занятия по теме «Глубокое обучение с подкреплением: глубокое обучение на языке Python, часть 7».

В этой объёмной статье мы расширим наше понимание обучения методом временных разниц. В частности, как вы знаете, до этого момента мы рассматривали так называемый метод TD(0). Но есть и более обобщённая его форма – TD(λ). Эта часть полностью посвящена тому, как расширить метод TD(0), чтобы получить TD(λ). Вы увидите, что TD(λ) предоставляет собой компромисс между одношаговыми методами обучения вроде TD(0) и вычислением полной отдачи, как в методе Монте-Карло.

Первое, что необходимо узнать при обсуждении TD(λ), – это метод N шагов, и именно ему и посвящена данная лекция. Работает он следующим образом. Мы уже знаем, что функция ценности может быть описана рекурсивно:

Мы также знаем, что можем оценить V путём усреднения отдач по множеству различных эпизодов:

А ещё из метода TD(0) мы знаем, что можем оценить само G, используя текущую оценку V:

А ещё из метода TD(0) мы знаем, что можем оценить само G, используя текущую оценку V:

Справедливо спросить, что является наиболее точным в этой оценке G? Это должно быть вознаграждение r, ведь это фактическая величина, полученная из эксперимента. Тогда вполне вероятно, что если мы используем больше значений r и меньше значений V, то наша оценка G станет более точной. Например, вместо того, чтобы брать первое вознаграждение, а затем использовать V для оценки остального, можно взять первое вознаграждение, затем следующее за ним вознаграждение, а затем использовать для оценки остального следующее V:

Напомним, что метод Монте-Карло является крайним случаем, когда мы вовсе не используем V:

Заметьте, мы можем использовать верхний индекс для G, чтобы видеть, сколько используется R:

Как видите, это даёт нам нечто вроде дискретного перехода от метода TD(0) к методу Монте-Карло. Поскольку обучение методом Монте-Карло означает, что агент может быть обновлён только по окончании эпизода, а метод TD(0) означает, что агент может быть обновлён после одного этапа, то использование N этапов фактических вознаграждений означает, что необходимо выждать окончания N этапов, прежде чем агент может быть обновлён, так что можно сформировать целевую переменную, использовав полученные фактические вознаграждения. В общем случае, если мы хотим использовать n шагов R, то может создать целевую переменную следующим образом:

Для обновления V, если мы решаем проблему предсказания, уравнение обновления не меняется; единственное изменение – это само G: нужно подождать n шагов, прежде чем можно будет обновлять V для состояния, в котором мы сейчас находимся. Это, разумеется, относится как к табличным методам, так и методам приближения функций:

И, конечно же, это можно перенести на проблему управления, когда мы используем Q вместо V. Если мы выполняем алгоритм SARSA, обновление будет соответствовать избранному действию, а действие будет выбираться жадным или эпсилон-жадным алгоритмом относительно Q:

Метод N шагов в коде

Мы используем уже написанную программу Q-обучения для машины на склоне, чтобы, кроме всего прочего, можно было сосредоточиться на части с методом N шагов. Если вы не хотите писать код самостоятельно, хотя я настоятельно рекомендую сделать именно так, то соответствующий этой лекции файл называется n_step.py в папке mountaincar.

Вначале у нас идут все обычные импорты, а также кое-что из программы с Q-обучением:

from __future__ import print_function, division

from builtins import range

# Note: you may need to update your version of future

# sudo pip install -U future

#

# Note: gym changed from version 0.7.3 to 0.8.0

# MountainCar episode length is capped at 200 in later versions.

# This means your agent can’t learn as much in the earlier episodes

# since they are no longer as long.  

#

# Adapt Q-Learning script to use N-step method instead

import gym

import os

import sys

import numpy as np

import matplotlib.pyplot as plt

from gym import wrappers

from datetime import datetime

# code we already wrote

import q_learning

from q_learning import plot_cost_to_go, FeatureTransformer, Model, plot_running_avg

Следующим идёт определение собственного класса SGDRegressor. Я обнаружил, что он работает лучше, чем SGDRegressor библиотеки Sci-kit Learn. Реализация та же, что и прежде, разве что пришлось изменить конструктор, так чтобы он принимал любой аргумент.

class SGDRegressor:

  def __init__(self, **kwargs):

    self.w = None

    self.lr = 10e-3

Весовые коэффициенты так же инициируются в функции partial_fit, как это делается в SGDRegressor библиотеки Sci-kit Learn:

  def partial_fit(self, X, Y):

    if self.w is None:

      D = X.shape[1]

      self.w = np.random.randn(D) / np.sqrt(D)

    self.w += self.lr*(Y – X.dot(self.w)).dot(X)

  def predict(self, X):

    return X.dot(self.w)

# replace SKLearn Regressor

q_learning.SGDRegressor = SGDRegressor

# calculate everything up to max[Q(s,a)]

# Ex.

# R(t) + gamma*R(t+1) + … + (gamma^(n-1))*R(t+n-1) + (gamma^n)*max[Q(s(t+n), a(t+n))]

# def calculate_return_before_prediction(rewards, gamma):

#   ret = 0

#   for r in reversed(rewards[1:]):

#     ret += r + gamma*ret

#   ret += rewards[0]

#   return ret

Далее у нас идёт функция play_one. Обратите внимание на то, что она сложнее, чем наши предыдущие функции play_one. Это связано с тем обстоятельством, что мы должны отслеживать n последних вознаграждений, состояний и действий. Вначале мы инициируем все обычные переменные, а также переменную multiplier, которая нужна для вычислений в методе N шагов. Мы знаем, что множители должны быть равны γ0, γ1, и так далее до γn-1. В связи с этим лучше их вычислить заранее:

# returns a list of states_and_rewards, and the total reward

def play_one(model, eps, gamma, n=5):

  observation = env.reset()

  done = False

  totalreward = 0

  rewards = []

  states = []

  actions = []

  iters = 0

  # array of [gamma^0, gamma^1, …, gamma^(n-1)]

  multiplier = np.array([gamma]*n)**np.arange(n)

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

  # while not done and iters < 200:

  while not done and iters < 10000:

    # in earlier versions of gym, episode doesn’t automatically

    # end when you hit 200 steps

    action = model.sample_action(observation, eps)

    states.append(observation)

    actions.append(action)

    prev_observation = observation

    observation, reward, done, info = env.step(action)

    rewards.append(reward)

    # update the model

    if len(rewards) >= n:

      # return_up_to_prediction = calculate_return_before_prediction(rewards, gamma)

      return_up_to_prediction = multiplier.dot(rewards[-n:])

      G = return_up_to_prediction + (gamma**n)*np.max(model.predict(observation)[0])

      model.update(states[-n], actions[-n], G)

    # if len(rewards) > n:

    #   rewards.pop(0)

    #   states.pop(0)

    #   actions.pop(0)

    # assert(len(rewards) <= n)

    totalreward += reward

    iters += 1

Выйдя из цикла, надо иметь в виду, что всё ещё остаются некоторые не обновлённые состояния, которые также необходимо обновить. Не забывайте, что в новейших версиях Gym эпизод заканчивается на 200-м шаге, независимо от того, достигли вы цели или нет; в более же старых версиях мы просто продолжаем процесс, пока не достигнем цели. В связи с этим тяжело узнать, достигли ли мы цели, пока не проверим документацию. В документации же говорится, что цели достигнута, когда позиция достигает 0,5, и именно это мы и проверяем. Если цель достигнута, то все последующие вознаграждения можно считать равными нулю, если же не достигнута, то мы должны предположить, что не достигнём её и в последующие n шагов, а потому устанавливаем все последующие вознаграждения равными -1. Именно поэтому соответствующая переменная называется guess_rewards («угадай_вознаграждение»).

  # empty the cache

  rewards = rewards[-n+1:]

  states = states[-n+1:]

  actions = actions[-n+1:]

  # unfortunately, new version of gym cuts you off at 200 steps

  # even if you haven’t reached the goal.

  # it’s not good to do this UNLESS you’ve reached the goal.

  # we are “really done” if position >= 0.5

  if observation[0] >= 0.5:

    # we actually made it to the goal

    # print(“made it!”)

    while len(rewards) > 0:

      G = multiplier[:len(rewards)].dot(rewards)

      model.update(states[0], actions[0], G)

      rewards.pop(0)

      states.pop(0)

      actions.pop(0)

  else:

    # we did not make it to the goal

    # print(“didn’t make it…”)

    while len(rewards) > 0:

      guess_rewards = rewards + [-1]*(n – len(rewards))

      G = multiplier.dot(guess_rewards)

      model.update(states[0], actions[0], G)

      rewards.pop(0)

      states.pop(0)

      actions.pop(0)

  return totalreward

Далее  у нас идёт раздел main, с которым вы уже должны быть знакомы.

if __name__ == ‘__main__’:

  env = gym.make(‘MountainCar-v0’)

  ft = FeatureTransformer(env)

  model = Model(env, ft, “constant”)

  gamma = 0.99

  if ‘monitor’ in sys.argv:

    filename = os.path.basename(__file__).split(‘.’)[0]

    monitor_dir = ‘./’ + filename + ‘_’ + str(datetime.now())

    env = wrappers.Monitor(env, monitor_dir)

  N = 300

  totalrewards = np.empty(N)

  costs = np.empty(N)

  for n in range(N):

    # eps = 1.0/(0.1*n+1)

    eps = 0.1*(0.97**n)

    totalreward = play_one(model, eps, gamma)

    totalrewards[n] = totalreward

    print(“episode:”, n, “total reward:”, totalreward)

  print(“avg reward for last 100 episodes:”, totalrewards[-100:].mean())

  print(“total steps:”, -totalrewards.sum())

  plt.plot(totalrewards)

  plt.title(“Rewards”)

  plt.show()

  plot_running_avg(totalrewards)

  # plot the optimal state-value function

  plot_cost_to_go(env, model)

Итак, запустим программу и посмотрим, что у нас получится.

TD-лямбда

Теперь обсудим TD(λ) как обобщение метода N шагов.

λ – это параметр, связанный с вектором, который называется следом соответствия и с которым мы познакомимся в этой лекции. Вы могли заметить, что код для метода N шагов был довольно сложным. В частности, было не так и просто всё время отслеживать последние конечные вознаграждения. λ позволяет нам использовать более элегантный метод выбора компромисса между TD(0) и методом Монте-Карло. В частности, нам не нужно будет отслеживать последние N шагов и мы будем способны делать обновления без ожидания окончания этих N шагов, прождав лишь 1 шаг, как в методе TD(0). Мы увидим, что λ = 1 соответствует методу Монте-Карло, λ = 0 – методу TD(0), а любое значение между этими величинами является компромиссом между этими методами.

Итак, вначале обобщим метод N шагов. Не забывайте, что мы можем определить различные G в зависимости от количества R, от которых он зависит:

Возникает странная идея – сформировать отдачу, которая являлась бы комбинацией этих G, к примеру,

Перенесём эту странность на следующий уровень. Предположим, мы можем суммировать любое количество G, даже бесконечное. Единственное требование – чтобы сумма весовых коэффициентов этих G была равна 1, так как конечное G должно быть сопоставимо с исходными значениями G:

Перенесём эту странность на следующий уровень. Предположим, мы можем суммировать любое количество G, даже бесконечное. Единственное требование – чтобы сумма весовых коэффициентов этих G была равна 1, так как конечное G должно быть сопоставимо с исходными значениями G:

В методе TD(λ) λ присваиваются очень специфические значения. В частности, оно уменьшается в геометрической прогрессии и, если вы заметили, в формуле сумма не сходится к единице, поскольку уже первый весовой коэффициент равен 1, а потому их нужно нормализовать. Конкретно говоря, нас интересует нормализация для случая, когда n равно бесконечности. Из курса математического анализа мы знаем, что данная сумма равна единице, делённой на единицу 1 – λ:

Следовательно, мы должны отмасштабировать сумму отдач на величину 1 – λ. Это называется λ-отдачей:

Следовательно, мы должны отмасштабировать сумму отдач на величину 1 – λ. Это называется λ-отдачей:

Можете поставить видео на паузу и выписать всё это на бумаге, чтобы самим удостовериться, что всё правильно.

Имея λ-отдачу, можно предположить, что наш эпизод в какой-то момент закончится. Обозначим этот момент времени через T. Поскольку считается, что n пробегает значения от 1 до бесконечности, когда n достигает значения Tt, отдача на n-м шаге на самом деле становится лишь полной отдачей выборки G(t), без верхнего индекса. Тогда мы можем преобразовать λ-отдачу, чтобы отделить отдачи на n-м шаге и отдачи полной выборки:

Второй член можно преобразовывать до тех пор, пока не останется лишь часть, зависящая от n внутри суммы:

Далее, если использовать некоторые тождества, относящиеся к рядам, то можно найти решение для второй суммы в аналитическом виде:

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

Следует отметить, что если в этом уравнении λ = 0, то мы получим отдачу от одного шага, поскольку 00 = 1. Это и есть метод TD(0). Если же λ = 1, то первый член равен нулю, а мы получаем полную отдачу – что составляет метод Монте-Карло. Любое значение λ между этими двумя величинами даёт нам комбинацию всех отдач для n шагов, убывающих в геометрической прогрессии.

Тут возникает очевидный вопрос: а не хуже ли всё это как метода Монте-Карло, так и метода N шагов с точки зрения вычислительных усилий? В методе Монте-Карло нам нужно лишь вычислить отдачи для эпизода, в методе N шагов – вычислить отдачу N-го шага для каждого момента времени. Теперь же нам надо вычислить как отдачу по методу Монте-Карло, так и отдачи от N = 1 и до конца эпизода. Выглядит так, будто необходимо проделать намного больше работы, причём совершенно неясно, в чём тут выгода.

Следующий этап требует кое-какого математического «волшебства», которое определённо выходит за рамки данного курса, а потому никаких доказательств приводится не будет. Впрочем, этот метод известен уже некоторое время, так что у вас не должно возникнуть трудностей с поиском статей, в которых обсуждается данный алгоритм. Тот же алгоритм, который мы рассмотрим, в действительности является лишь приближением для использования истинной λ-отдачи, которую мы только что обсуждали. Конечно же, на самом деле нам не нужно использовать истинную λ-отдачу ни в какой практической ситуации, поскольку это означало бы вычисление отдач всех различных n шагов и полной отдачи.

Прежде всего давайте вспомним, как выглядят обновления наших параметров в методе TD(0). У нас есть целевая переменная, равная Rt+1 + γV(st+1), прогноз, равный V(st), и разница между ними δt = Rt+1 + γV(st+1) – V(st)  (эта δ называется TD-ошибкой). Это помогает нам немного более строго всё записать, но это по-прежнему просто выполнение градиентного спуска:

Для реализации метода TD(λ) нам необходимо ввести идею следа соответствия. След соответствия – это вектор той же размерности, что и наш вектор параметров θ. По сути, он отслеживает наши старые градиенты, во многом подобно импульсу в глубоком обучении. Мы определяем первый след соответствия как равный нулю, а затем каждый последующий след будет равным текущему градиенту плюс часть старого следа соответствия, причём эта часть – лишь произведение γ и λ:

Для реализации метода TD(λ) нам необходимо ввести идею следа соответствия. След соответствия – это вектор той же размерности, что и наш вектор параметров θ. По сути, он отслеживает наши старые градиенты, во многом подобно импульсу в глубоком обучении. Мы определяем первый след соответствия как равный нулю, а затем каждый последующий след будет равным текущему градиенту плюс часть старого следа соответствия, причём эта часть – лишь произведение γ и λ:

По сути, λ просто показывает, какую часть прошлого мы принимаем во внимание.

Теперь мы можем переопределить этап обновления параметров, использовав след соответствия вместо простого градиента:

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

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

Обратите внимание на то, что, поскольку обновление θ теперь зависит только от следующего состояния, мы можем выполнять наши обновления уже на следующем шаге, а не ждать N этапов, как в методе N шагов. Однако не забывайте и о том, что это лишь приближение для использования истинной λ-отдачи.

Тут иногда отмечается ещё одно отличие. Поскольку метод N шагов и истинная λ-отдача требуют просмотра позднейших состояний, они называются прямым представлением, тогда как в методе TD(λ) мы обновляем наше текущее θ, основываясь на прошлых ошибках, и потому это называется обратным представлением.

TD-лямбда в коде

Давайте воплотим алгоритм метода TD(λ) в коде. Мы используем тот же подход, что и в программе для метода N шагов, когда мы импортировали большую часть кода из более ранней программы для Q-обучения. При просмотре кода следует отметить, насколько он проще метода N шагов, несмотря на то что его теоретические основы сложнее. Если вы не хотите писать код самостоятельно, хотя я настоятельно рекомендую сделать именно так, соответствующий файл в репозитарии называется td_lambda.py и находится в папке mountaincar.

Вначале у нас идут все обычные импорты.

#***

from __future__ import print_function, division

from builtins import range

# Note: you may need to update your version of future

# sudo pip install -U future

#

# Note: gym changed from version 0.7.3 to 0.8.0

# MountainCar episode length is capped at 200 in later versions.

# This means your agent can’t learn as much in the earlier episodes

# since they are no longer as long.

#

# Adapt Q-Learning script to use TD(lambda) method instead

import gym

import os

import sys

import numpy as np

import matplotlib.pyplot as plt

from gym import wrappers

from datetime import datetime

# code we already wrote

from q_learning import plot_cost_to_go, FeatureTransformer, plot_running_avg

После этого идёт определение класса BaseModel. Он подобен написанному нами ранее классу SGDRegressor, однако я не хочу его так называть, поскольку теперь мы используем следы соответствия.

class BaseModel:

  def __init__(self, D):

    self.w = np.random.randn(D) / np.sqrt(D)

Я также переименовал некоторые аргументы функции fit, чтобы вы могли видеть, что есть что.

  def partial_fit(self, input_, target, eligibility, lr=10e-3):

    self.w += lr*(target – input_.dot(self.w))*eligibility

Функция predict – та же, как обычно.

  def predict(self, X):

    X = np.array(X)

    return X.dot(self.w)

Далее у нас идёт класс Model. Он будет немного сложнее, так как теперь мы должны отслеживать векторы соответствия. В конструкторе мы инициируем BaseModel для каждого из действий и вектор соответствия.

# Holds one BaseModel for each action

class Model:

  def __init__(self, env, feature_transformer):

    self.env = env

    self.models = []

    self.feature_transformer = feature_transformer

    D = feature_transformer.dimensions

    self.eligibilities = np.zeros((env.action_space.n, D))

    for i in range(env.action_space.n):

      model = BaseModel(D)

      self.models.append(model)

Функция predict остаётся прежней.

  def predict(self, s):

    X = self.feature_transformer.transform([s])

    assert(len(X.shape) == 2)

    return np.array([m.predict(X)[0] for m in self.models])

Самые большие изменения – в функции update. Прежде всего не забывайте, что часть обновления вектора соответствия – это просто γ, умноженное на λ и умноженное на само себя, поэтому мы можем вычислить это первым делом. Следующее – добавить градиент, который, как мы знаем, является собственно вектором признаков:

  def update(self, s, a, G, gamma, lambda_):

    X = self.feature_transformer.transform([s])

    assert(len(X.shape) == 2)

    self.eligibilities *= gamma*lambda_

    self.eligibilities[a] += X[0]

    self.models[a].partial_fit(X[0], G, self.eligibilities[a])

Функция sample_action также остаётся прежней.

  def sample_action(self, s, eps):

    if np.random.random() < eps:

      return self.env.action_space.sample()

    else:

      return np.argmax(self.predict(s))

Затем у нас идёт функция play_one. Не думаю, что тут что-то может удивить, – весь код мы видели в предыдущих программах. У нас лишь появляется, как и следовало ожидать, дополнительный аргумент λ, однако мы по-прежнему выполняем Q-обучение. Обратите внимание, насколько проще и короче стала функция по сравнению с методом N шагов: нам больше не нужно ни отслеживать все вознаграждения, ни создавать цикл, чтобы пройти по всем оставшимся состояниям после окончания эпизода:

# returns a list of states_and_rewards, and the total reward

def play_one(model, eps, gamma, lambda_):

  observation = env.reset()

  done = False

  totalreward = 0

  iters = 0

  # while not done and iters < 200:

  while not done and iters < 10000:

    action = model.sample_action(observation, eps)

    prev_observation = observation

    observation, reward, done, info = env.step(action)

    # update the model

    G = reward + gamma*np.max(model.predict(observation)[0])

    model.update(prev_observation, action, G, gamma, lambda_)

    totalreward += reward

    iters += 1

  return totalreward

Далее у нас идёт раздел main, который остаётся в точности прежним.

if __name__ == ‘__main__’:

  env = gym.make(‘MountainCar-v0’)

  ft = FeatureTransformer(env)

  model = Model(env, ft)

  gamma = 0.99

  lambda_ = 0.7

  if ‘monitor’ in sys.argv:

    filename = os.path.basename(__file__).split(‘.’)[0]

    monitor_dir = ‘./’ + filename + ‘_’ + str(datetime.now())

    env = wrappers.Monitor(env, monitor_dir)

  N = 300

  totalrewards = np.empty(N)

  costs = np.empty(N)

  for n in range(N):

    # eps = 1.0/(0.1*n+1)

    eps = 0.1*(0.97**n)

    # eps = 0.5/np.sqrt(n+1)

    totalreward = play_one(model, eps, gamma, lambda_)

    totalrewards[n] = totalreward

    print(“episode:”, n, “total reward:”, totalreward)

  print(“avg reward for last 100 episodes:”, totalrewards[-100:].mean())

  print(“total steps:”, -totalrewards.sum())

  plt.plot(totalrewards)

  plt.title(“Rewards”)

  plt.show()

  plot_running_avg(totalrewards)

  # plot the optimal state-value function

  plot_cost_to_go(env, model)

Итак, запустим программу и посмотрим, что у нас получится.

TD-лямбда. Итоги

В заключение подведём итог всему изученному в данной части.

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

Вначале мы рассмотрели метод N шагов как обобщение метода TD(0). TD(0) имеет одношаговую отдачу, так как использует вознаграждение от одного примера. Но мы видели, что можем использовать любое количество вознаграждений в наших вычислениях отдачи. В частности, использование N вознаграждений даёт нам N-шаговую отдачу. Если же мы возьмём N до конца эпизода, то у нас получится метод Монте-Карло.

Далее мы увидели, что можем объединить все N-шаговые отдачи до бесконечности, что даёт нам λ-отдачу. В этой ситуации мы взвешиваем каждую N-шаговую отдачу на λ, убывающую в геометрической прогрессии. Мы видели, преобразуя наше выражение для λ-отдачи, что λ = 0 соответствует методу TD(0), а λ = 1, что ещё называется методом TD(1), – методу Монте-Карло.

Затем мы рассмотрели оперативный алгоритм с использованием следов соответствия, который приближённо выражает оперативное значение TD(λ). Интересно и обнадёживающе, что алгоритм TD(λ) во многом схож на обновление импульса из глубокого обучения.

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

Андрей Никитенко
Андрей Никитенко
Задать вопрос эксперту
Понравилась статья? Поделить с друзьями: