Рубрики
Без рубрики

Если у вас есть медленные петли в Python, вы можете это исправить … пока не не сможете

Автор оригинала: FreeCodeCapm Team.

Максимом Мамаевым

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

Установка сцены: проблема Knaxackack

Это вычислительная проблема, которую мы будем использовать в качестве примера:

Проблема Knaxackack – это хорошо известная проблема в комбинаторной оптимизации. В этом разделе мы рассмотрим свой самый распространенный аромат, 0-1 Knapsack Проблема и его решение с помощью динамического программирования. Если вы знакомы с предметом, вы можете Пропустить эту часть Отказ

Вам дают рюкзак емкости C и коллекция N Предметы. У каждого предмета есть вес w [я] и ценность v [я] Отказ Ваша задача состоит в том, чтобы упаковать рюкзак с самыми ценными элементами. Другими словами, вы должны максимизировать общую стоимость предметов, которые вы помещаете в тему Knaxackack, с ограничением: общий вес взятых предметов не может превышать емкость рюкзака.

После того, как у вас есть решение, общий вес элементов в knaxackack называется «Вес раствора», и их общая стоимость является «значением решения».

Проблема имеет много практических приложений. Например, вы решили инвестировать 1600 долларов в знаменитый запас Faang (коллективное название для акций Facebook, Amazon, Apple, Netflix и Google Aka AlkaBet). Каждая акция имеет текущую рыночную цену и однолетнюю ценовую оценку. По состоянию на один день в 2018 году они следующие:

========= ======= ======= =========Company   Ticker  Price   Estimate========= ======= ======= =========Alphabet  GOOG    1030    1330Amazon    AMZN    1573    1675Apple     AAPL    162     193 Facebook  FB      174     216 Netflix   NFLX    312     327========= ======= ======= =========

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

Это проблема рюкзака. Ваш бюджет ($ 1600) – это мешок Емкость (в) Отказ Акции являются предметами, которые будут упакованы. Текущие цены – Вес (W) Отказ Оценки цен являются Значения Отказ Проблема выглядит тривиально. Тем не менее, решение не очевидно на первом взгляде – следует ли купить одну долю Amazon или одну долю Google Plus One каждая из каких-либо комбинаций Apple, Facebook или Netflix.

Конечно, в этом случае вы можете делать быстрые расчеты вручную и прибыть на решение: вы должны купить Google, Netflix и Facebook. Таким образом, вы проводите $ 1516 и ожидаете получить 1873 долл. США.

Теперь вы верите, что вы обнаружили Klondike. Вы разбиваете свою копилку и собираете 10 000 долларов. Несмотря на ваше волнение, вы остаетесь адамант с правилом «Один акций – одна покупка». Поэтому с этим более крупным бюджетом вы должны расширить ваши варианты. Вы решили рассмотреть все акции из списка NASDAQ 100 как кандидатов на покупку.

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

Динамический алгоритм программирования

Мы должны бросить подход грубой силы и программировать некоторые умные решения. Небольшие проблемы Knaxackack (и наши маленький, считают, что это или нет) решаются динамическим программированием. Основная идея состоит в том, чтобы начать с тривиальной проблемы, решение которого мы знаем, а затем добавьте сложность пошаговая.

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

Предположим, что, учитывая первый Я Предметы коллекции, мы знаем значения решения S (I, K) Для всех потенциала Knaxackack к В диапазоне от 0 до C Отказ

Другими словами, мы шили C + 1 «Вспомогательные» рюкзаки всех размеров от 0 до C Отказ Тогда мы отсортировали нашу коллекцию, взяли первую Я предмет и временно отложите все остальное. И теперь мы предполагаем, что, по какой-то магии, мы знаем, как оптимально упаковать каждый из мешков из этого рабочего набора Я Предметы. Предметы, которые мы выбираем из рабочего набора, могут отличаться для разных мешков, но на данный момент нам не интересно, какие предметы мы берем или пропускаем. Это только значение решения S (I, K) Что мы записываем для каждого из наших недавно сшитых мешков.

Теперь мы принесем следующий, (I + 1) TH, предмет из коллекции и добавьте его в рабочий набор. Давайте найдем значения решения для всех вспомогательных рюкзаков с помощью этого нового рабочего набора. Другими словами, мы находим S (I + 1, K) Для всех k = 0..c Дано S (I, K) Отказ

Если к меньше веса нового предмета w [i + 1] мы не можем взять этот предмет. Действительно, даже если мы взяли Только Этот пункт, он один не будет вписываться в рюкзак. Поэтому S (I + 1, (I, K) Для всех K +1].

Для значений k [я +1] Мы должны сделать выбор: либо мы принимаем новый элемент в рюкзак Capaci T y k или мы пропускаем это. Нам нужно оценить эти два варианта, чтобы определить, какой из них дает нам большее значение, упакованное в мешок.

Если мы возьмем (I + 1) Т-й пункт, мы приобретаем ценность v [i + 1] и потреблять часть емкости рюкзака для размещения веса w [i + 1] Отказ Это оставляет нас с емкостью k-w [I + 1] которые мы должны оптимально заполнить использование (некоторые из) первого Я Предметы. Это оптимальное заполнение имеет значение раствора S (I, K-w [I + 1]) Отказ Этот номер уже известен нам, потому что, по предположению, мы знаем все значения решения для рабочего набора Я Предметы. Следовательно, кандидатское значение решения для Knaxackack к С товаром Я + 1 принято будет S (I + 1, K | I + 1 [I + 1] + S (I, K-w [I + 1]) .

Другой вариант – пропустить предмет Я + 1 Отказ В этом случае ничто не изменится в нашем рюкзаке, и стоимость решения кандидата будет такой же, как S (I, K) Отказ

Чтобы выбрать лучший выбор, мы сравниваем два кандидата для значений решения: S (I + 1, K | I + 1 [I + 1] + S (I, K-W [I + 1]) S (I + 1, K | I + 1 (I, K)

Максимум из них становится решением S (I + 1, K) Отказ

В итоге:

if k < w[i+1]:    s(i+1, k) = s(i, k)else:    s(i+1, k) = max( v[i+1] + s(i, k-w[i+1]), s(i, k) )

Теперь мы можем решить проблему Knaxackack Shap-Step. Начнем с пустого рабочего набора ( I = 0 ) Отказ Очевидно, s (0, для любого k . Затем мы предпринимаем шаги, добавляя элементы в рабочий набор и нахождение значений решения S (I, K) до тех пор, пока не приеду на S (I + 1 = N,) Это значение раствора исходной задачи.

Обратите внимание, что, кстати, мы построили сетку NXC Значения решения.

Тем не менее, несмотря на изучение стоимости решения, мы точно не знаем, какие предметы были взяты в рюкзак. Чтобы найти это, мы возвращаемся сеткой. Начиная с S (I = N,) Мы сравним S (I, K) с S (I-1, K) Отказ

Если S (I, (I-1, k) , i th товар не был взят. Мы повторяемся с i = i-1 Сохранение ценности k без изменений. В противном случае, I I TH товар был взят и на следующий шаг экзамена мы сокращаем рюкзак по w [i] – мы настроили i = i-1, -w [i]

Таким образом, мы изучаем все предметы из N до первого, и определить, кто из них был введен в рюкзак. Это дает нам решение проблемы Knaxackack.

Код и анализ

Теперь, как у нас есть алгоритм, мы сравним несколько реализаций, начиная с простого. Код доступен на Github Отказ

Данные – это NASDAQ 100 Список, содержащий текущие цены и оценки цен на сто фондовых акций (по состоянию на один день в 2018 году). Наш инвестиционный бюджет составляет 10 000 долларов.

Напомним, что цены на акции не являются круглыми долларами, но приходят с центами. Поэтому, чтобы получить точное решение, мы должны подсчитать все в центах – мы определенно хотим избежать поплавковых чисел. Следовательно, емкость нашего рюкзака составляет ($) 10000 × 100 центов = ($) 1000000, а общий размер нашей проблемы N X C 000 000.

С целым числом берут 4 байта памяти, мы ожидаем, что алгоритм будет потреблять примерно 400 МБ ОЗУ. Итак, память не будет ограничением. Это время исполнения, о котором мы должны заботиться о.

Конечно, все наши реализации даст одно и то же решение. Для справки, инвестиции (вес решения) составляет 999930 ($ 9999.30), а ожидаемая доходность (значение решения) составляет 1219475 ($ 12944,75). Список акций для покупки довольно длинный (80 из 100 предметов). Вы можете получить его, запустив код.

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

Простые старые “для” петель

Просторная реализация алгоритма приведена ниже.

Есть две части.

В первой части (линии 3-7 выше), два вложенных для Петли используются для создания решетки раствора.

Внешняя петля добавляет элементы к рабочему набору, пока мы не достигнем N (значение n пропускается в параметре предметы ). Строка значения раствора для каждого нового рабочего набора инициализирована со значениями, вычисленными для предыдущего рабочего набора.

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

Обратите внимание, что нам не нужно начать цикл от k = 0 Отказ Когда к меньше веса Предмет , значения раствора всегда такие же, как вычисленные для предыдущего рабочего набора, и эти цифры уже были скопированы в текущую строку по инициализации.

Когда петли завершены, у нас есть решетка раствора и значение раствора.

Вторая часть (линии 9-17) – это один для петля N итерации. Он отступает от сетки, чтобы найти то, какие предметы были взяты в рюкзак.

Далее мы сосредоточимся исключительно на первой части алгоритма, как это имеет O (n * c) Сложность времени и космического пространства. Выделение Backtracking требует только O (n) Время и не проводит какую-либо дополнительную память – его потребление ресурсов относительно незначительно.

Требуется 180 секунд для простой реализации для решения NASDAQ 100 Проблема Knaxackack на моем компьютере.

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

Для получения некоторого эталона, давайте программируем один и тот же алгоритм на другом языке. Нам нужен статически напечатанный скомпилированный язык для обеспечения скорости вычисления. Нет, не C. Это не нравится. Мы будем придерживаться моды и писать в Go:

Как видите, код Go очень похож на это в Python. Я даже копировал одну одну линию, самую длинную, как есть.

Что такое время работы? 400 миллисекунд ! Другими словами, Python вышел в 500 раз медленнее, чем идти. Разрыв, вероятно, будет еще больше, если мы попробовали его в C. Это определенно катастрофа для Python.

Чтобы узнать, что замедляет код Python, давайте запустим его с Линия Profiler Отказ Вы можете найти продукцию профилирования для этого и последующих реализаций алгоритма в Github Отказ

В простом решалете 99,7% времени работы проводится в двух строках. Эти две линии включают внутреннюю петлю, которая выполняется 98 миллионов раз:

Я прошу прощения за чрезмерно длинные линии, но линия Profiler не может правильно обрабатывать разрывы линии в том же операторе.

Я слышал, что Python’s для Оператор медленный, но, интересно, самое время проводится не в для линия, но в теле петли.

Мы можем сломать тело петли в отдельные операции, чтобы увидеть, слишком ли какая-либо конкретная операция слишком медленная:

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

Обратите внимание, насколько разбит код вниз увеличил общее время работы. Внутренняя петля теперь занимает 99,9% времени работы. Техник вашего кода Python, медленный он получает. Интересно, не так ли?

Встроенная функция карты

Давайте сделаем код более оптимизированным и замените внутреннюю для петля со встроенным карта () Функция:

Время выполнения этого кода является 102 секунды , находясь на 78 секунд от простых результатов. Действительно, карта () Беги заметно, но не подавляюще, быстрее.

Список понимания

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

Это закончено в 81 секунд Отказ Мы достигли другого улучшения и сократить время пробега в половину по сравнению с простым внедрением (180 сек). Из контекста это было бы оценено как значительный прогресс. Увы, мы все еще легкие годы от нашего эталона 0,4 сек.

Numpy массивы

Наконец, мы исчерпали встроенные инструменты Python. Да, я слышу рев аудитории, повторяя “Numpy! Numpy!” Но чтобы оценить эффективность Numpy, мы должны были поставить его в контекст, пытаясь для , карта () и перечислить понимание заранее.

Хорошо, теперь это простое время. Итак, мы отказываемся от списков и поместите наши данные в Numpy Armays:

Внезапно результат обескураживает. Этот код проходит в 1,5 раза медленнее, чем решатель понимания списка ванильного списка ( 123 SEC по сравнению с 81 сек). Как это может быть?

Давайте рассмотрим профили линии для обеих решений.

Инициализация Сетка [0] В качестве Numpy Array (строка 274) в три раза быстрее, чем когда это список Python (линия 245). Внутри внешней петли, инициализация Сетка [Предмет + 1] в 4,5 раза быстрее для простого массива (строка 276), чем для списка (строка 248). Все идет нормально.

Однако выполнение линии 279 – в 1,5 раза медленнее, чем его numpy-менее аналоговый в строке 252. Проблема в том, что понимание списка создает Список ценностей, но мы храним эти значения в Numpy Array который находится на левой стороне выражения. Следовательно, эта линия неявно добавляет накладные расходы преобразования списка в небольшой массив. С линией 279 приходится 99,9% времени работы, все ранее отмеченные преимущества Numpy становятся незначительными.

Но нам все еще нужно средство для Итерация через массивы, чтобы сделать расчеты. Мы уже узнали, что понимание списка является самым быстрым инструментом итерации. (Кстати, если вы попытаетесь создать numpy массивы внутри простого старого для петля, избегая преобразования списка To-numpy-массива, вы получите пробежку 295 секунд времени работы.) Итак, мы застряли и Numpy без использования? Конечно нет.

Правильное использование Numpy

Просто хранение данных в Numpy массивов не делает трюк. Реальная мощность Numpy поставляется с функциями, которые запускают расчеты по применению Numpy. Они принимают массивы в качестве параметров и возвратных массивов в качестве результатов.

Например, есть функция где () который занимает три массива в качестве параметров: Состояние , х и y и возвращает массив, построенный, выбирая элементы из х или из y Отказ Первый параметр Состояние это массив логических. Он говорит, где выбрать от: если элемент Состояние оценивается до Правда соответствующий элемент х отправляется на вывод, в противном случае элемент из y взят.

Обратите внимание, что функция NUMPY делает все это в одном вызове. Завораживание через массивы устанавливаются под капотом.

Вот как мы используем где () как замена внутреннего для петля в первом решале или соответственно понимание списка последних:

Есть три куска кода, которые интересны: строка 8, строка 9 и строки 10-13, как указано выше. Вместе они заменяют внутреннюю цикл, которая бы переиграла все возможные размеры Knaxacks, чтобы найти значения раствора.

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

grid[item+1, :this_weight] = grid[item, :this_weight]

Затем мы строим вспомогательный массив Temp (линия 9):

temp = grid[item, :-this_weight] + this_value

Этот код аналогичен, но намного быстрее, чем:

[grid[item, k — this_weight] + this_value  for k in range(this_weight, capacity+1)]

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

Обратите внимание, как Temp Массив построен, добавив скаляр на массив. Это еще одна мощная особенность Numpy под названием «вещание». Когда Numpy видит операнды с разными размерами, он пытается расширить (то есть для «трансляции») низкоразмерный операнд для соответствия размерам другого. В нашем случае скаляр расширяется до массива того же размера, что и Сетка [Предмет: --this_weight] И эти две массивы добавляются вместе. В результате стоимость this_value добавляется в каждый элемент Сетка [Предмет: --this_weight] – ни одна цикла не требуется.

В следующей части (линии 10-13) мы используем функцию где () Что означает, что именно то, что требуется по алгоритму: он сравнивает два значения решения для каждого размера Knaxackack и выбирает тот, который больше.

grid[item + 1, this_weight:] =                 np.where(temp > grid[item, this_weight:],             temp,             grid[item, this_weight:])

Сравнение делается по Состояние Параметр, который рассчитывается как TEMP> Сетка [Предмет, this_weigh T:]. Это элемент-мудрое операция, которая создает массив логических значений, один для каждого размера вспомогательного рюкзака. Т .| R Значение UE означает, что соответствующий элемент должен быть упакован в рюкзак. Следовательно, значение раствора, взятое из массива, является вторым аргументом Functio п, т портить В противном случае элемент должен быть пропущен, а значение раствора копируется из предыдущего ряда сетки - третий аргумент t он тогда E () Функция.

Наконец, Darp Drive занимается! Этот решатель выполняет в 0,55 сек Отказ Это в 145 раз быстрее, чем решатель на основе понимания списка и в 329 раз быстрее, чем код, использующий для петля. Хотя мы не опередили решателя, написанные в Go (0,4 сек), мы пришли довольно близко к нему.

Некоторые петли должны остаться

Подожди, но как насчет внешнего для петля?

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

Можем ли мы переписать внешний цикл, используя функцию Numpy аналогичным образом к тому, что мы сделали с внутренним циклом? Ответ – нет.

Несмотря на оба быть для Петли, внешние и внутренние петли довольно разные в том, что они делают.

Внутренняя цикла производит 1D-массив на основе другого 1D-массива, элементы которого являются Все известно когда цикл запускается. Именно это предыдущая доступность входных данных, которые позволили нам заменить внутреннюю цикл либо карта () , список пометки или функция numpy.

Внешняя петля производит 2D-массив из 1D-массивов, элементы которых являются не известен, когда цикл начинается. Кроме того, эти компонентные массивы вычисляются рекурсивным алгоритмом: мы можем найти элементы (I + 1) массив только после того, как мы нашли Я господство

Предположим, что внешняя петля может быть представлена как функция: Сетка (ROW0, ROW1, ... ROOND) Все параметры функций должны быть оценены до того, как функция называется только Row0 известен заранее. Так как вычисление (I + 1) TH ряд зависит от наличия Я TH, нам нужен цикл, идущий от 1 к N вычислить все ряд Параметры. Следовательно, чтобы заменить внешнюю петлю функцией, нам нужен другой цикл, которая оценивает параметры этой функции. Этот другой цикл является точно, что петля, которую мы пытаемся заменить.

Другой способ избежать внешнего для Цикл – использовать рекурсию. Можно легко написать рекурсивную функцию Рассчитать (i) что производит Я ряд сетки. Для того, чтобы выполнить работу, функция должна знать (I-1) TH ряд, таким образом, он называет себя Рассчитать (I-1) а затем вычисляет Я ряд, используя функции numpy, как мы сделали раньше. Затем вся внешняя петля может быть заменена на Рассчитать (n) Отказ Чтобы сделать снимок завершенной, в исходном коде можно найти рекурсивный рюверный решанк, сопровождающий эту статью на Github Отказ

Однако рекурсивный подход явно не масштабируется. Питон не оптимизирован. Глубина рекурсионного стека по умолчанию ограничена порядком одной тысячи. Этот предел, безусловно, является консервативным, но, когда нам требуется глубина миллионов, переполнение стека очень вероятно. Более того, эксперимент показывает, что рекурсия даже не предоставляет преимуществом производительности над Numpy-Ballver с внешним для петля.

Именно здесь мы исчерпываем инструменты, предоставляемые Python и его библиотеками (насколько мне известно). Если вам абсолютно необходимо ускорить петлю, которая реализует рекурсивный алгоритм, вам придется прибегать к Cython или скомпилированной JIT-составной версией Python или на другой язык.

Вынос

  • Считайте численные расчеты с функциями Numpy. Они на два порядка быстрее, чем встроенные инструменты Python.
  • Встроенных инструментов Python, понимание списка быстрее, чем карта () , что значительно быстрее, чем для Отказ
  • Для глубокого рекурсивных алгоритмов петель более эффективны, чем рекурсивные вызовы функций.
  • Вы не можете заменить рекурсивные петли с помощью карта () , список пометки или функция numpy.
  • «Тупой» код (разбитый в элементарные операции) – самый медленный. Используйте встроенные функции и инструменты.