Документация Engee

Документация разработчика

По сути, Interpolations.jl поддерживает две операции: построение и использование интерполянтов.

Построение интерполянта

В результате построения создается объект интерполяции. В некоторых случаях это сравнительно простой процесс: например, при использовании только интерполяционных схем NoInterp, Constant или Linear построение фактически соответствует регистрации массива значений и «параметров» (интерполяционной схемы), заданных в момент построения. Такая простота объясняется тем, что интерполированные значения можно вычислить непосредственно на основе сеточных значений, предоставленных при построении: воссоздает при .

Для Quadratic и более высоких порядков эффективное вычисление требует предварительной фильтрации массива значений. Это фактически соответствует «обращению» вычисления, которое будет выполняться в ходе интерполяции, с целью приблизительно воссоздать исходные значения в точках на сетке. В общем случае это соответствует решению близкой к тридиагональной системы уравнений с обращением базовой интерполяционной схемы так, что для некоторых функций и (дополнительные сведения см. в разделе Quadratic). Здесь  — это предварительно отфильтрованная версия a такая, что подстановка (для которой могут быть получены отличные от 0 и 1 значения при вызове и соответственно) приблизительно восстанавливает .

Точный вид системы уравнений, которую нужно решить, зависит от порядка интерполяции и граничных условий. Граничные условия часто вводят отклонения от идеальной тридиагональности; с этими «дополнительными условиями» эффективно справляется пакет WoodburyMatrices. Данные вычисления реализуются независимо для каждой оси с помощью пакета AxisAlgorithms.

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

Филипп Тевеназ (Philippe Thévenaz), Тьерри Блю (Thierry Blu) и Майкл Унсер (Michael Unser). Interpolation revisited. IEEE Transactions on Medical Imaging 19.7 (2000): стр. 739—​758.

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

Использование интерполянта

Под использованием понимается вычисление выражений вида itp(x, y...), Interpolations.gradient(itp, x, y...) и т. д. Само по себе использование состоит из двух шагов: вычисления весов и выполнения интерполяции.

Вычисление весов

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

Ключевой концепцией на этом шаге является тип Interpolations.WeightedIndex, но приводить здесь всю информацию из его подробного docstring не имеет смысла. Достаточно будет добавить, что WeightedIndex — это по сути абстрактный тип с двумя конкретными подтипами:

  • WeightedAdjIndex для индексов, по которым будет происходить обращение к смежным точкам массива коэффициентов (точкам, индексы которых увеличиваются на 1 по соответствующему измерению). Они применяются, если в ходе предварительной фильтрации происходит заполнение, которое можно использовать даже на границах, либо для таких интерполяционных схем, как Linear, которые не требуют заполнения.

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

Способ вычисления WeightedIndex зависит от интерполяционной схемы (например, Linear или Quadratic), а также от того, вычисляются ли значения, градиенты (gradient) или гессианы (hessian). Порядок обработки производных более подробно описывается ниже.

Интерполяция

Общие массивы AbstractArray можно индексировать с помощью индексов WeightedIndex, и в результате получается интерполированное значение. Иными словами, конечным результатом является просто itp.coefs[wis...], где wis — кортеж индексов WeightedIndex. Чтобы такая перегрузка действовала, мы заключаем coefs в InterpGetindex, то есть InterpGetindex(itp.coefs)[wis...]

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

В качестве демонстрации давайте посмотрим, как происходит следующее вычисление.

julia> A = reshape(1:27, 3, 3, 3)
3×3×3 reshape(::UnitRange{Int64}, 3, 3, 3) with eltype Int64:
[:, :, 1] =
 1  4  7
 2  5  8
 3  6  9

[:, :, 2] =
 10  13  16
 11  14  17
 12  15  18

[:, :, 3] =
 19  22  25
 20  23  26
 21  24  27

julia> itp = interpolate(A, BSpline(Linear()));

julia> x = (1.2, 1.4, 1.7)
(1.2, 1.4, 1.7)

julia> itp(x...)
8.7

Используя средства отладки среды IDE, такой как Juno или VSCode, или применяя Debugger.jl из REPL, можно легко пошагово выполнить код с заходом в приведенный выше вызов, чтобы отслеживать его работу параллельно с описанием ниже.

Если оставить в стороне такие детали, как проверка границ, главный вызов — это вызов метода Interpolations.weightedindexes:

julia> wis = Interpolations.weightedindexes((Interpolations.value_weights,), Interpolations.itpinfo(itp)..., x)
(Interpolations.WeightedAdjIndex{2, Float64}(1, (0.8, 0.19999999999999996)), Interpolations.WeightedAdjIndex{2, Float64}(1, (0.6000000000000001, 0.3999999999999999)), Interpolations.WeightedAdjIndex{2, Float64}(1, (0.30000000000000004, 0.7)))

julia> wis[1]
Interpolations.WeightedAdjIndex{2, Float64}(1, (0.8, 0.19999999999999996))

julia> wis[2]
Interpolations.WeightedAdjIndex{2, Float64}(1, (0.6000000000000001, 0.3999999999999999))

julia> wis[3]
Interpolations.WeightedAdjIndex{2, Float64}(1, (0.30000000000000004, 0.7))

julia> Interpolations.InterpGetindex(A)[wis...]
8.7

Можно заметить, что каждый компонент wis соответствует определенной позиции: 1,2, 1,4 и 1,7 соответственно. Мы можем обратиться к A по индексу wis, и в результате будет возвращено значение itp(x...), которое в данном случае будет таким:

  0.8 * A[1, wis[2], wis[3]] + 0.2 * A[2, wis[2], wis[3]]
= 0.6 * (0.8 * A[1, 1, wis[3]] + 0.2 * A[2, 1, wis[3]]) +
  0.4 * (0.8 * A[1, 2, wis[3]] + 0.2 * A[2, 2, wis[3]])
= 0.3 * (0.6 * (0.8 * A[1, 1, 1] + 0.2 * A[2, 1, 1]) +
         0.4 * (0.8 * A[1, 2, 1] + 0.2 * A[2, 2, 1])  ) +
  0.7 * (0.6 * (0.8 * A[1, 1, 2] + 0.2 * A[2, 1, 2]) +
         0.4 * (0.8 * A[1, 2, 2] + 0.2 * A[2, 2, 2])  )

Было вычислено значение itp при x..., потому что метод weightedindexes был вызван с единственной функцией, Interpolations.value_weights (а это означает, что «для весов требовалось вычислить значение»).

Помните, что для интерполяции Linear предварительная фильтрация не используется. При использовании предварительной фильтрации мы бы заменили InterpGetindex(A)[wis...] на InterpGetindex(itp.coefs)[wis...] в приведенном выше коде.

Для вычисления производных мы также передаем дополнительные функции, например Interpolations.gradient_weights:

julia> wis = Interpolations.weightedindexes((Interpolations.value_weights, Interpolations.gradient_weights), Interpolations.itpinfo(itp)..., x)
((Interpolations.WeightedAdjIndex{2,Float64}(1, (-1.0, 1.0)), Interpolations.WeightedAdjIndex{2,Float64}(1, (0.6000000000000001, 0.3999999999999999)), Interpolations.WeightedAdjIndex{2,Float64}(1, (0.30000000000000004, 0.7))), (Interpolations.WeightedAdjIndex{2,Float64}(1, (0.8, 0.19999999999999996)), Interpolations.WeightedAdjIndex{2,Float64}(1, (-1.0, 1.0)), Interpolations.WeightedAdjIndex{2,Float64}(1, (0.30000000000000004, 0.7))), (Interpolations.WeightedAdjIndex{2,Float64}(1, (0.8, 0.19999999999999996)), Interpolations.WeightedAdjIndex{2,Float64}(1, (0.6000000000000001, 0.3999999999999999)), Interpolations.WeightedAdjIndex{2,Float64}(1, (-1.0, 1.0))))

julia> wis[1]
(Interpolations.WeightedAdjIndex{2,Float64}(1, (-1.0, 1.0)), Interpolations.WeightedAdjIndex{2,Float64}(1, (0.6000000000000001, 0.3999999999999999)), Interpolations.WeightedAdjIndex{2,Float64}(1, (0.30000000000000004, 0.7)))

julia> wis[2]
(Interpolations.WeightedAdjIndex{2,Float64}(1, (0.8, 0.19999999999999996)), Interpolations.WeightedAdjIndex{2,Float64}(1, (-1.0, 1.0)), Interpolations.WeightedAdjIndex{2,Float64}(1, (0.30000000000000004, 0.7)))

julia> wis[3]
(Interpolations.WeightedAdjIndex{2,Float64}(1, (0.8, 0.19999999999999996)), Interpolations.WeightedAdjIndex{2,Float64}(1, (0.6000000000000001, 0.3999999999999999)), Interpolations.WeightedAdjIndex{2,Float64}(1, (-1.0, 1.0)))

julia> Interpolations.InterpGetindex(A)[wis[1]...]
1.0

julia> Interpolations.InterpGetindex(A)[wis[2]...]
3.000000000000001

julia> Interpolations.InterpGetindex(A)[wis[3]...]
9.0

В этом случае можно заметить, что wis — 3-элементный кортеж из 3-элементных кортежей. A[wis[i]...] можно использовать для вычисления i-го компонента градиента.

Если внимательно посмотреть на каждый элемент в wis, можно заметить, что в каждом «внутреннем» 3-элементном кортеже повторяются два из трех элементов компонента wis, полученного выше при вызове weightedindexes только с функцией value_weights. wis[1] заменяет первый элемент взвешенным индексом с весами (-1.0, 1.0), что соответствует вычислению углового коэффициента по измерению. Аналогичным образом, wis[2] и wis[3] заменяют соответственно второй и третий индексы значений таким же вычислением углового коэффициента.

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

Таким образом, производные в определенных направлениях вычисляются путем простой «замены весов» по соответствующим измерениям.

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

Поддержка GPU

В настоящее время Interpolations.jl поддерживает использование интерполянтов на GPU посредством трансляции.

Базовый рабочий процесс выглядит так:

julia> using Interpolations, Adapt, CUDA # Или любой другой пакет для работы с GPU

julia > itp = Interpolations.interpolate([1, 2, 3], (BSpline(Linear()))); # создаем объект интерполянта в памяти ЦП

julia> cuitp = adapt(CuArray{Float32}, itp); # адаптируем его к памяти GPU

julia > cuitp.(1:0.5:2) # вызываем объект интерполянта посредством трансляции

julia> gradient.(Ref(cuitp), 1:0.5:2)

Для этой цели в ITP <: AbstractInterpolation должен быть определен собственный метод Adapt.adapt_structure(to, itp::ITP), который создает новый объект ITP с адаптированными полями (adapt(to, itp.fieldname)) itp. Адаптацию полей можно пропустить, если известно, что объект уже совместим с GPU, как в случае, например, с диапазоном isbit.

Некоторые адапторы могут изменять тип хранимых данных. Убедитесь в том, что у адаптированного объекта itp правильный тип элементов, с помощью метода eltype.

Кроме того, для всех совместимых с GPU типов AbstractInterpolation должен быть определен собственный метод Interpolations.root_storage_type. Эта функция позволяет изменять механизм трансляции, перегружая BroadcastStyle по умолчанию. Дополнительные сведения см. в разделе Настройка трансляции.