Wczytajmy pierw macierz sztywności z pliku:
Możemy sobie teraz wyświetlić gdzie ma ona niezerowe elementy:

Możemy teraz zobaczyć jej wartości własne

Jako, że macierz jest symetryczna wszystkie wartości własne są rzeczywiste. Warto zauważyć, że trzy ostatnie wartości własne są praktycznie równe zero:
| \(\lambda_{1}\) | \(\lambda_{2}\) | \(\cdots\) | \(\lambda_{248}\) | \(\lambda_{249}\) | \(\lambda_{250}\) | \(\lambda_{251}\) | \(\lambda_{252}\) | 
|---|---|---|---|---|---|---|---|
| 418.4178 | 411.8008 | \(\cdots\) | 1.554674 | 0.3134155 | 0 | 0 | 0 | 
Jest to związane z wolnymi stopniami swobody naszego układu. Możemy pozbyć się tych stopni zastępując odpowiednie wiersze wierszami macierzy jednostkowej
fix = c(1,2,42)   # Wybrane wiersze
I = diag(nrow(S)) # Macierz jednostkowa o odpowiednim rozmiarze
S[fix,]=I[fix,]   # Zastępujemy wiersze
S[,fix]=I[,fix]   # Zastępujemy kolumny (by macierz pozostała symetryczna)
lam = eigen(S)$val
barplot(lam,main="Wartości właśne S")
Teraz ostatnie wartości są już niezerowe
| \(\lambda_{1}\) | \(\lambda_{2}\) | \(\cdots\) | \(\lambda_{248}\) | \(\lambda_{249}\) | \(\lambda_{250}\) | \(\lambda_{251}\) | \(\lambda_{252}\) | 
|---|---|---|---|---|---|---|---|
| 418.4139 | 411.7858 | \(\cdots\) | 1 | 1 | 0.7062446 | 0.208298 | 0.0559314 | 
Rozważmy teraz przyłożenie siły w 80-tym stopniu swobody. Wektor sił \(F\) będzie miał zera wszędzie poza 80-tym elementem.
Odkształcenie spowodowane taką siłą to \(d = S^{-1}F\).

Odkształcenie to w tej formie trudno zinterpretować. Po pierwsze co drugi element oznacza odkształcenie w X a co drugi w Y:

Tutaj warto zauważyć, że choć macierz \(S\) jest rzadka (ma mało niezerowych elementów) tak macierz \(S^{-1}\) będzie pełna:

Można to sobie wyobrazić w następujący sposób. \(k\)-ta kolumna macierzy odwrotnej \(S^{-1}\) jest równa \(S^{-1}v\), gdzie \(v\) to wektor samych zer z jedynką na \(k\)-tym miejscu. Można więc powiedzieć, że \(k\)-ta kolumna macierzy \(S^{-1}\) to odkształcenie belki przy przyłożeniu jednoskowej siły w \(k\)-tym stopniu swobody. Jak wiemy, niezależnie w którym stopniu swobody przyłożymy siłę, odkształcenia będą występowały we wszystkich stopniach swobody. Oznacza to, że wszystkie kolumny macierzy \(S^{-1}\) będą pełne niezerowych elementów.
Zobaczmy 80-tą kolumnę:

Koszt policzenia \(S^{-1}\) jest bardzo wysoki. Nawet przechowanie takiej macierzy może być niemożliwe. Dlatego w większości zagadnień numerycznych nigdy nie liczona jest bezpośrednio odwrotność macierzy, lecz rozwiązywany jest zadany układ \(Sx=F\). W naszym wypadku prawa strona to wektor sił \(F\), zaś niewiadoma \(x\) to odkształcenie. Rozwiązać taki układ można na wiele sposobów. Dla dużych układów liniowych i nieliniowych wykożytujemy metody iteracyjne. Mają one na celu nie roziwiązanie problemu w ogólności (znalezienie \(S^{-1}\)) lecz w jak najmniejszej ilości iteracji znalezienie najlepszego przybliżenia rozwiązania \(x\). Lecz jak można sprawdzić czy rozwiązanie jest bliskie dokładnego, jeśli dokładne nie jest znane?
Weźmy dowolny wektor:

Jedyne co możemy stwierdzić to jak daleko jesteśmy od spełnienia naszego układu, odejmując lewą od prawej strony równania:

Taki wektor nazywamy residualem. Zazwyczaj by go zmierzyć jedną liczbą mówimy że residual to \(\|r\|_2=\sqrt{\sum_ir_i^2}\). Aktualnie wynosi on 2909.149542.
Zauważmy, że residual mówi nam jak daleko jesteśmy od rozwiązania, ale nie mówi nam praktycznie w jakim kierunku mamy iść. Jesteśmy w \(x\), chcemy być \(S^{-1}F\), lecz jedyne co wiemy to nie kierunek \(S^{-1}F - x\), lecz \(F - Sx\). Żeby wiedzieć gdzie należy iść, musimy mieć jakiekolwiek przybliżenie \(S^{-1}\). Takie przybliżenie nazywamy preconditionerem. Zwyczajowo piszemy, że construujemy \(P\simeq S\), tak by \(P^{-1}\simeq S^{-1}\) - lecz w rzeczywistości należy myśleć o preconditionerze jako o operatorze \(P^{-1}\), ponieważ wewnątrz programu, tylko on jest realizowany. Najprostrzym przykładem preconditionera jest odwrotność przekątnej macierzy (preconditioner Jacobiego):
Nie ma dobrej metody stwierdzenia, że dana macierz będzie dobrym preconditionerem dla innej, poza zbadaniem właściwości \(P^{-1}S\). Chcielibyśmy by macierz ta była możliwie bliska macierzy jednostkowej. W dużych zagadnieniach oczywiście takiej możliwości zbadania nie mamy, lecz w naszym przykładzie możemy przyjżeć się tej macierzy.

Widzimy, że w porównaniu z \(S\) macierz ta ma wartości zgromadzone w około \(1\).
Patrząc w dziedzinie zespolonej, zależy nam by \(\lambda\) były wszystkie jak najbardziej zgromadzone blisko \(1\):

Podobnie prostym preconditionerem jest wzięcie za \(P\) dolnej trójkątnej częsci \(S\) (preconditioner Gaussa-Seidla):
Taka macierz jest też bardzo łatwa do odwrócenia. Daje ona bardzo przyzwoity rozkład wartości własnych:

Widzimy natychmiast, że wartości te nie są już czysto rzeczywiste - ponieważ \(P\) nie jest teraz symmetryczna.
Weźmy więc nasz najprostrzy preconditioner
I w każdej iteracji przesuńmy się o: \(P^{-1}r \simeq S^{-1}r = S^{-1}(F-Sx) = S^{-1}F-x\). To powinno nas doprowadzić do rozwiązania:
x = x0
for (i in 1:itmax) {
  r = F - S %*% x
  p = solve(P,r)
  x = x + p
  if (residual(i,r,"Jacobi")) break
}
draw.residuals()
Widzimy natychmiast, że residual zamiast spadać rozbiega się wykładniczo. Jeśli spojżymy na naszą procedurę, to odmnarza ona nieustannie \(x\) przez \((I-P^{-1}S)\) ponieważ: \[x = x + p = x + P^{-1}r = x + P^{1}(F-Sx) = (I-P^{-1}S)x + P^{-1}F\] Oznacza to, że wartości własne \((I-P^{-1}S)\) poza jednostkowym okręgiem będą powodować rozbierzność.

By uniknąć takiej sytuacji możemy wprowadzić współczynnik relaksacji \(\alpha < 1\), który zeskaluje wartości własne by mieściły się w stabilnym okręgu:

Metoda z tak dobranym współczynnikiem jest już zbierzna:
delete.residuals("Jacobi")
alpha = 0.7
x = x0
for (i in 1:itmax) {
  r = F - S %*% x
  p = solve(P,r)
  x = x + alpha*p
  if (residual(i,r,"Jacobi alpha=0.7")) break
}
draw.residuals()
Weźmy teraz znów jako preconditioner macierz górno-trójkątną.
Warto zauważyć, że dla preconditionera Gaussa-Seidla, można dobrać współczynnik \(\alpha\) nawet większy od jedynki (nadrelaksacja):

alpha = 1.5
x = x0
for (i in 1:itmax) {
  r = F - S %*% x
  p = solve(P,r)
  x = x + alpha*p
  if (residual(i,r,"Gauss-Seidl alpha=1.5")) break
}
draw.residuals()
Łatwo zauważyć że dobór jednej wartości \(\alpha\) na wszystkie iteracje jest nieoptymalny. W każdym kroku należałoby dobierać \(\alpha\) oddzielnie. Jeśli weźmiemy sume kwadratów residuali: \(f(\alpha) = \sum_i r_i^2 = r^T r\), to możemy dobrać tak \(\alpha\) by \(f\) było minimalne po iteracji. Residual po iteracji \(r^{(n+1)}\) jest równy: \[r^{(n+1)} = F - Sx^{(n+1)} = F - Sx^{n} - \alpha Sp = r^{(n)} - \alpha Sp\] Różniczkując \(f\) po \(\alpha\)i przyrównując pochodną do zera dostajmey: \[\alpha=\frac{r^T (Sp)}{(Sp)^T(Sp)}\]
Zobaczmy taką metodę dla preconditionera diagonalnego:
By lepiej zobaczyć co się dzieję zapiszemy wszystkie kolejne \(\alpha\) w wektorze, by później je zwizualizować.
x = x0
alphas = NULL
for (i in 1:itmax) {
  r = F - S %*% x
  p = solve(P,r)
  Ap = S %*% p
  alpha = sum(r * Ap)/sum(Ap * Ap)
  x = x + alpha*p
  alphas = c(alphas,alpha)
  if (residual(i,r,"Basic MINRES")) break
}
draw.residuals()
Jak widać w przy takim optymalnym doborze \(\alpha\) residual monotonicznie spada. Możemy teraz zobaczyć jakie wartości przyjmował ten współczynnik w trakcie procesu zbierzności:

Jak widać Wykraczają one wielokrotnie poza zakres stabinych współczynników podrelaksacji dla metody Jacobiego. Oznacza to że w danej iteracji współczynnik \(\alpha\) nie jest ograniczony przez najbardziej odstające wartości własne \(P^{-1}S\).
Dość niejednorodne zachowanie metody MINRES wynika z częstej sytuacji w której to co zyskaliśmy w jednym kroku “tracimy” w następnym. Metoda monotonicznie redukuje residual, więc w rzeczywistości nie “tracimy” w kolejnej iteracji, co raczej “nie uzyskujemy tyle ile byśmy mogli”. Można sobie to wyobrazić w następujący sposób. Załużmy że chcemy dotrzeć do portu na jeziorze, lecz z powodu wiatru możemy wykonywać tylko ruchy po skosie. Jeśli będziemy płynąć zawsze w danym kierunku, aż będziemy najbliżej portu (minimalne residuum) i dopiero wtedy wykonywać zwrot, w następnym ruchu musimy znów dużo przepłynąć.
Weźmy więc jezioro i port (\(b\)) na nim.

Do tego weźmy macierz \(A\) która będzie realizować zwykłą odległość euklidesową i preconditioner który będzie dawał nam skośne kierunki ruchu:
Dla zadanego punktu początkowego \(x_0\) możemy policzyć residual i kierunek \(p\):

Dla takiego układu optymalna \(\alpha\) to krok dla którego punkt \(x_1\) będzie najbliżej portu. Można ją policzyć za pomocą tego samego wzoru:
Wynosi ona 0.6535693, więc łódka powędruje nie cały wektor \(p\) lecz jego kawałek.

Jednak \(\alpha\) w nowym kierunku jest wysoka (2.7389444) i punkt x znów przesunie się daleko po skosie.
draw.lake(b)
x = c(-0.5,0.5)
for (i in 1:12) { 
  r = b - A %*% x
  p = solve(P,r)
  draw.boat(x,p,NA)
  Ap = A %*% p
  alpha = sum(r * Ap)/sum(Ap * Ap)
  x = x + alpha*p
}
By polepszyć zbierzność, możemy zachować poprzedni kierunek \(p\) i uzyć go do poprawienia następnej iteracji. Załużmy, że preconditioner wskazuje nam pewien kierunek \(p=P^{-1}r\), zaś w poprzedniej iteracji poruszyliśmy się w kierunku \(q\). Możemy usunąć część \(p\) która już w poprzedniej iteracji uzyskaliśmy modyfikując kierunek \(p\) za pomocą \(p' = p - \beta q\). Stosując analogiczne rozumowanie jak porzednio otrzymamy \(\beta=\frac{(Sq)^T(Sp)}{(Sq)^T(Sq)}\), a dokładniej: \[\beta=\left[(Sq)^T(Sq)\right]^{-1}\left[(Sq)^T(Sp)\right]\]
x = x0
for (i in 1:itmax) {
  r = F - S %*% x
  p = solve(P,r)
  if (i != 1) {
    Ap = S %*% p
    Aq = S %*% q
    beta = sum(Ap * Aq)/sum(Aq * Aq)
    p = p - beta * q
  }
  Ap = S %*% p
  alpha = sum(r * Ap)/sum(Ap * Ap)
  x = x + alpha*p
  if (residual(i,r,"MINRES + beta")) break
  q = p
}
draw.residuals()
Widać natychmiast, że działanie tak zmodyfikowanej metody MINRES jest szybsze, ale też bardziej stabilne (bez długich płaskich fragmentów). Rozumowanie takie można pociągnąć dłużej i zachowywać wszystkie wcześniejsze wektory, by użyć ich by poprawiać każdy kolejny kierunek.
x = x0
q = NULL
for (i in 1:itmax) {
  r = F - S %*% x
  p = solve(P,r)
  if (i != 1) {
    Ap = S %*% p
    Aq = S %*% q
    beta = solve(t(Aq) %*% Aq, t(Aq) %*% Ap)
    p = p - q %*% beta
  }
  Ap = S %*% p
  alpha = sum(r * Ap)/sum(Ap * Ap)
  x = x + alpha*p
  if (residual(i,r,"MINRES + beta (full)")) break
  q = cbind(p,q)
}
draw.residuals()
Taka metoda ma bardzo szybką zbierzność, lecz wymaga w ogólności przechowywania bardzo dużej ilości wektorów. Wymaga także odwracania macierzy \(\left[(Sq)^T(Sq)\right]\) która z iteracji na iterację robi się coraz większa. Macierz ta jest też często źle uwarunkowana i poprawna implementacja tej metody wymaga troche dodatkowej uwagi. Zazwyczaj resetuje się zestaw wektorów \(q\) co jakiś czas by uniknąć narastających problemów.
Jeśli macierz \(A\) jest symmetryczna, to rozwiązanie układu \(Ax=b\) jest równoważnie nie tylko z minimalizacją residualu (\(r^Tr\)), ale też z minimalizacją \(a(x) = x^TAx - 2 x^T b\). Jedyna różnica jaka z tego wynika, to inna formuła na \(\alpha\) i \(\beta\): \[\alpha=\frac{r^T A p}{p^T A p}\] \[\beta=\left[q^T A q\right]^{-1}\left[q^T A p\right]\]
x = x0
q = NULL
for (i in 1:itmax) {
  r = F - S %*% x
  p = solve(P,r)
  if (i != 1) {
    Aq = S %*% q
    beta = solve(t(Aq) %*% q, t(Aq) %*% p)
    p = p - q %*% beta
  }
  Ap = S %*% p
  alpha = sum(r * p)/sum(p * Ap)
  x = x + alpha*p
  if (residual(i,r,"Symmetric, positive MINRES (full)")) break
  q = cbind(p,q)
}
draw.residuals()
Taka metoda ma także bardzo dobrą zbierzność. Widać jednak na wykresie, że zbierzność jej jest nie monotoniczna:

Nie jest to niespodziewane, ponieważ minimalizujemy teraz \(x^TAx-xb\) (i funkcja ta monotocznie spada), a nie \(\sum_i r^2_i\).
To czego nie widać odrazu, to to, że wektor \(\beta\) we wszystkich iteracjach ma tylko jeden niezerowy element (odpowiadający \(q\) bezpośrednio z poprzedniej iteracji). Dla przykładu \(\beta\) z ostatniej iteracji to:
| \(\beta_{1}\) | \(\beta_{2}\) | \(\cdots\) | \(\beta_{105}\) | \(\beta_{106}\) | \(\beta_{107}\) | \(\beta_{108}\) | \(\beta_{109}\) | 
|---|---|---|---|---|---|---|---|
| -0.2660422 | 0 | \(\cdots\) | 0 | 0 | 0 | 0 | 0 | 
Nie jest to przypadek, tylko analityczna konsekwencja poprzednich iteracji. Pokazanie tego pozostawiam jako dobre ćwiczenie dla czytelnika. Właściwość ta (przypominam: dla macierzy symmetrycznych i dodatnio określonych) powoduje, że wystarczy zapisać jeden wektor \(q\) by uzyskać tak samo dobrą zbierzność jak przy zapisywaniu wszystkich. Taka metoda nazywa się Conjugate Gradient:
x = x0
for (i in 1:itmax) {
  r = F - S %*% x
  p = solve(P,r)
  if (i != 1) {
    Aq = S %*% q
    beta = sum(Aq * p)/sum(Aq * q)
    p = p - q %*% beta
  }
  Ap = S %*% p
  alpha = sum(r * p)/sum(p * Ap)
  x = x + alpha*p
  if (residual(i,r,"Conjugate Gradient")) break
  q = p
}
draw.residuals()
Warto na koniec zauważyć, że metoda ta ma na tyle dobrą zbierzność, że możemy jej użyć nawet bez jakiegokolwiek preconditionera (i troche przeorganizować kolejność obliczeń):
x = x0
r = F - S %*% x
q = r
for (i in 1:itmax) {
  if (residual(i,r,"Conjugate Gradient (no precond)")) break
  Aq = S %*% q
  alpha = sum(r * q)/sum(q * Aq)
  x = x + alpha*q
  r = r - alpha*Aq
  beta = sum(r * Aq)/sum(q * Aq)
  q = r - beta * q
}
draw.residuals()
Ilość iteracji potrzbnych do osiągnięcia wymaganego residuum:
it = tapply(resid$iteration,resid$name,max)
it[it==itmax] = paste(">",itmax)
kable(data.frame(Iteracji=it))| Iteracji | |
|---|---|
| Jacobi alpha=0.7 | > 2000 | 
| Gauss-Seidl alpha=1.5 | > 2000 | 
| Basic MINRES | > 2000 | 
| MINRES + beta | 551 | 
| MINRES + beta (full) | 109 | 
| Symmetric, positive MINRES (full) | 110 | 
| Conjugate Gradient | 110 | 
| Conjugate Gradient (no precond) | 126 |