Разреженные проблемы собственных значений с ARPACK#
Введение#
ARPACK [1] является пакетом на Fortran, который предоставляет подпрограммы для быстрого поиска нескольких собственных значений/собственных векторов больших разреженных матриц. Для нахождения этих решений требуется только умножение слева на рассматриваемую матрицу. Эта операция выполняется через обратная связь интерфейс. Результат этой структуры в том, что ARPACK может находить собственные значения и векторы любой линейной функции, отображающей вектор в вектор.
Весь функционал, предоставляемый в ARPACK, содержится в двух
высокоуровневых интерфейсах scipy.sparse.linalg.eigs и
scipy.sparse.linalg.eigsh. eigs
предоставляет интерфейсы для нахождения
собственных значений/векторов вещественных или комплексных несимметричных квадратных матриц, в то время как
eigsh предоставляет интерфейсы для вещественно-симметричных или комплексно-эрмитовых
матриц.
Базовая функциональность#
ARPACK может решать либо стандартные задачи на собственные значения вида
или общие проблемы собственных значений вида
Сила ARPACK заключается в том, что он может вычислять только указанное подмножество пар собственных значений/векторов. Это достигается с помощью ключевого слова
which. Следующие значения which доступны:
which = 'LM': Собственные значения с наибольшей величиной (eigs,eigsh), то есть наибольшие собственные значения в евклидовой норме комплексных чисел.which = 'SM': Собственные значения с наименьшей величиной (eigs,eigsh), то есть наименьшие собственные значения в евклидовой норме комплексных чисел.which = 'LR': Собственные значения с наибольшей вещественной частью (eigs).which = 'SR': Собственные значения с наименьшей вещественной частью (eigs).which = 'LI': Собственные значения с наибольшей мнимой частью (eigs).which = 'SI': Собственные значения с наименьшей мнимой частью (eigs).which = 'LA': Собственные значения с наибольшим алгебраическим значением (eigsh), то есть наибольшие собственные значения, включая любой отрицательный знак.which = 'SA': Собственные значения с наименьшим алгебраическим значением (eigsh), то есть наименьшие собственные значения, включая любой отрицательный знак.which = 'BE': Собственные значения с обоих концов спектра (eigsh).
Заметим, что ARPACK обычно лучше находит экстремальные собственные значения, то есть
собственные значения с большими величинами. В частности, используя which = 'SM'
может привести к медленному времени выполнения и/или аномальным результатам. Лучший подход
— использовать режим shift-invert.
Режим сдвига-инверсии#
Режим сдвига-инверсии основан на следующем наблюдении. Для обобщённой задачи на собственные значения
можно показать, что
где
Примеры#
Представьте, что вы хотите найти наименьшие и наибольшие собственные значения и соответствующие собственные векторы для большой матрицы. ARPACK может обрабатывать множество форм ввода: плотные матрицы, такие как numpy.ndarray экземпляры, разреженные
матрицы, такие как scipy.sparse.csr_matrix, или общий линейный оператор,
полученный из scipy.sparse.linalg.LinearOperator. Для этого примера, для
простоты, мы построим симметричную, положительно-определенную матрицу.
>>> import numpy as np
>>> from scipy.linalg import eig, eigh
>>> from scipy.sparse.linalg import eigs, eigsh
>>> np.set_printoptions(suppress=True)
>>> rng = np.random.default_rng()
>>>
>>> X = rng.random((100, 100)) - 0.5
>>> X = np.dot(X, X.T) # create a symmetric matrix
Теперь у нас есть симметричная матрица X, с помощью которого тестируются процедуры. Сначала вычислите стандартное разложение по собственным значениям, используя eigh:
>>> evals_all, evecs_all = eigh(X)
По мере увеличения размерности X растёт, эта процедура становится очень медленной. Особенно,
если требуется только несколько собственных векторов и собственных значений, ARPACK может быть
лучшим вариантом. Сначала вычислим наибольшие собственные значения (which = 'LM')
из X и сравнить их с известными результатами:
>>> evals_large, evecs_large = eigsh(X, 3, which='LM')
>>> print(evals_all[-3:])
[29.22435321 30.05590784 30.58591252]
>>> print(evals_large)
[29.22435321 30.05590784 30.58591252]
>>> print(np.dot(evecs_large.T, evecs_all[:,-3:]))
array([[-1. 0. 0.], # may vary (signs)
[ 0. 1. 0.],
[-0. 0. -1.]])
Результаты соответствуют ожиданиям. ARPACK восстанавливает нужные собственные значения, и они совпадают с ранее известными результатами. Кроме того, собственные векторы ортогональны, как и ожидалось. Теперь попробуем найти собственные значения с наименьшей величиной:
>>> evals_small, evecs_small = eigsh(X, 3, which='SM')
Traceback (most recent call last): # may vary (convergence)
...
scipy.sparse.linalg._eigen.arpack.arpack.ArpackNoConvergence:
ARPACK error -1: No convergence (1001 iterations, 0/3 eigenvectors converged)
Упс. Мы видим, что, как упоминалось выше, ARPACK не так хорошо справляется с поиском малых собственных значений. Есть несколько способов решения этой проблемы. Мы могли бы увеличить допуск (tol) для ускорения
сходимости:
>>> evals_small, evecs_small = eigsh(X, 3, which='SM', tol=1E-2)
>>> evals_all[:3]
array([0.00053181, 0.00298319, 0.01387821])
>>> evals_small
array([0.00053181, 0.00298319, 0.01387821])
>>> np.dot(evecs_small.T, evecs_all[:,:3])
array([[ 0.99999999 0.00000024 -0.00000049], # may vary (signs)
[-0.00000023 0.99999999 0.00000056],
[ 0.00000031 -0.00000037 0.99999852]])
Это работает, но мы теряем точность в результатах. Другой вариант — увеличить максимальное количество итераций (maxiter) с 1000 до 5000:
>>> evals_small, evecs_small = eigsh(X, 3, which='SM', maxiter=5000)
>>> evals_all[:3]
array([0.00053181, 0.00298319, 0.01387821])
>>> evals_small
array([0.00053181, 0.00298319, 0.01387821])
>>> np.dot(evecs_small.T, evecs_all[:,:3])
array([[ 1. 0. 0.], # may vary (signs)
[-0. 1. 0.],
[ 0. 0. -1.]])
Мы получаем ожидаемые результаты, но время вычислений значительно больше.
К счастью, ARPACK содержит режим, который позволяет быстро определить невнешние собственные значения: режим shift-invert. Как упомянуто выше, этот режим включает преобразование задачи на собственные значения в эквивалентную задачу с другими собственными значениями. В этом случае мы надеемся найти собственные значения около нуля, поэтому выберем sigma = 0. Преобразованные собственные значения будут
удовлетворять \(\nu = 1/(\lambda - \sigma) = 1/\lambda\), поэтому наши
малые собственные значения \(\lambda\) становятся большими собственными значениями \(\nu\).
>>> evals_small, evecs_small = eigsh(X, 3, sigma=0, which='LM')
>>> evals_all[:3]
array([0.00053181, 0.00298319, 0.01387821])
>>> evals_small
array([0.00053181, 0.00298319, 0.01387821])
>>> np.dot(evecs_small.T, evecs_all[:,:3])
array([[ 1. 0. 0.], # may vary (signs)
[ 0. -1. -0.],
[-0. -0. 1.]])
Мы получаем ожидаемые результаты с гораздо меньшими вычислительными затратами. Обратите внимание, что преобразование из \(\nu \to \lambda\) происходит полностью в фоновом режиме. Пользователю не нужно беспокоиться о деталях.
Режим shift-invert предоставляет больше, чем просто быстрый способ получения нескольких
малых собственных значений. Допустим, вы
хотите найти внутренние собственные значения и собственные векторы, например, ближайшие к
\(\lambda = 1\). Просто установите sigma = 1 и ARPACK позаботится об
остальном:
>>> evals_mid, evecs_mid = eigsh(X, 3, sigma=1, which='LM')
>>> i_sort = np.argsort(abs(1. / (1 - evals_all)))[-3:]
>>> evals_all[i_sort]
array([0.94164107, 1.05464515, 0.99090277])
>>> evals_mid
array([0.94164107, 0.99090277, 1.05464515])
>>> print(np.dot(evecs_mid.T, evecs_all[:,i_sort]))
array([[-0. 1. 0.], # may vary (signs)
[-0. -0. 1.],
[ 1. 0. 0.]]
Собственные значения выводятся в другом порядке, но все присутствуют.
Обратите внимание, что режим shift-invert требует внутреннего решения матричного
обращения. Это автоматически обрабатывается eigsh и eigs,
но операция также может быть указана пользователем. См. строку документации
scipy.sparse.linalg.eigsh и
scipy.sparse.linalg.eigs подробности.
Использование LinearOperator#
Теперь рассмотрим случай, когда вы хотите избежать создания плотной матрицы и использовать scipy.sparse.linalg.LinearOperator вместо этого. Наш первый
линейный оператор применяет поэлементное умножение между входным вектором
и вектором \(\mathbf{d}\) предоставленные пользователем оператору. Этот оператор имитирует диагональную матрицу с элементами \(\mathbf{d}\)
вдоль главной диагонали, и его основное преимущество в том, что прямые и сопряжённые операции являются простыми поэлементными умножениями, отличными от умножения матрицы на вектор. Для диагональной матрицы мы ожидаем, что собственные значения будут равны элементам вдоль главной диагонали, в данном случае
\(\mathbf{d}\). Собственные значения и собственные векторы, полученные с помощью eigsh
сравниваются с полученными при использовании eigh при применении к
плотной матрице:
>>> from scipy.sparse.linalg import LinearOperator
>>> class Diagonal(LinearOperator):
... def __init__(self, diag, dtype='float32'):
... self.diag = diag
... self.shape = (len(self.diag), len(self.diag))
... self.dtype = np.dtype(dtype)
... def _matvec(self, x):
... return self.diag*x
... def _rmatvec(self, x):
... return self.diag*x
>>> N = 100
>>> rng = np.random.default_rng()
>>> d = rng.normal(0, 1, N).astype(np.float64)
>>> D = np.diag(d)
>>> Dop = Diagonal(d, dtype=np.float64)
>>> evals_all, evecs_all = eigh(D)
>>> evals_large, evecs_large = eigsh(Dop, 3, which='LA', maxiter=1e3)
>>> evals_all[-3:]
array([1.53092498, 1.77243671, 2.00582508])
>>> evals_large
array([1.53092498, 1.77243671, 2.00582508])
>>> print(np.dot(evecs_large.T, evecs_all[:,-3:]))
array([[-1. 0. 0.], # may vary (signs)
[-0. -1. 0.],
[ 0. 0. -1.]]
В этом случае мы создали быстрый и простой Diagonal оператор.
Внешняя библиотека PyLops предоставляет
схожие возможности в Диагональ оператор,
а также несколько других операторов.
Наконец, мы рассматриваем линейный оператор, имитирующий применение шаблона первой производной. В этом случае оператор эквивалентен вещественной несимметричной матрице. Снова мы сравниваем оценённые собственные значения и собственные векторы с теми, что получены из плотной матрицы, применяющей ту же первую производную к входному сигналу:
>>> class FirstDerivative(LinearOperator):
... def __init__(self, N, dtype='float32'):
... self.N = N
... self.shape = (self.N, self.N)
... self.dtype = np.dtype(dtype)
... def _matvec(self, x):
... y = np.zeros(self.N, self.dtype)
... y[1:-1] = (0.5*x[2:]-0.5*x[0:-2])
... return y
... def _rmatvec(self, x):
... y = np.zeros(self.N, self.dtype)
... y[0:-2] = y[0:-2] - (0.5*x[1:-1])
... y[2:] = y[2:] + (0.5*x[1:-1])
... return y
>>> N = 21
>>> D = np.diag(0.5*np.ones(N-1), k=1) - np.diag(0.5*np.ones(N-1), k=-1)
>>> D[0] = D[-1] = 0 # take away edge effects
>>> Dop = FirstDerivative(N, dtype=np.float64)
>>> evals_all, evecs_all = eig(D)
>>> evals_large, evecs_large = eigs(Dop, 4, which='LI')
>>> evals_all_imag = evals_all.imag
>>> isort_imag = np.argsort(np.abs(evals_all_imag))
>>> evals_all_imag = evals_all_imag[isort_imag]
>>> evals_large_imag = evals_large.imag
>>> isort_imag = np.argsort(np.abs(evals_large_imag))
>>> evals_large_imag = evals_large_imag[isort_imag]
>>> evals_all_imag[-4:]
array([-0.95105652, 0.95105652, -0.98768834, 0.98768834])
>>> evals_large_imag
array([0.95105652, -0.95105652, 0.98768834, -0.98768834]) # may vary
Обратите внимание, что собственные значения этого оператора все мнимые. Более того,
ключевое слово which='LI' of scipy.sparse.linalg.eigs выдаёт собственные значения с наибольшей абсолютной мнимой частью (как положительной, так и отрицательной). Опять же, более продвинутая реализация оператора первой производной доступна в
PyLops библиотека под названием
FirstDerivative
оператор.