Animando transformaciones lineales con Quiver

El científico inevitablemente significa trabajar en múltiples capas de abstracción, las principales abstracciones de código y matemáticas. Esto es genial, porque esto le permite obtener resultados sorprendentes rápidamente. Pero a veces es bien aconsejado hacer una pausa por un segundo y reflexionar sobre lo que realmente sucede detrás de una interfaz ordenada. Este proceso de reflexión a menudo es asistido por visualizaciones. En este artículo, quiero presentar cómo las parcelas animadas pueden ayudar a reflexionar sobre las transformaciones lineales, que a menudo trabajan de manera confiable en la oscuridad de los algoritmos de aprendizaje automático e interfaces asociadas. Al final, podremos visualizar conceptos como la descomposición del valor singular con nuestra trama de carcaj.

Trazar gráficos estáticos de carcaj

Una trama de carcaj de la matplotlib El paquete Python nos permite trazar flechas (que en nuestro caso representan vectores). Primero echemos un vistazo a una trama estática de carcaj:

Imagen del autor

Podemos derivar directamente la matriz de transformación de la imagen mirando las posiciones objetivo de los dos vectores base. El primer vector base está comenzando en la posición (1, 0) y aterriza en (1, 1), mientras que el vector de segunda base viaja de (0, 1) a (-1, 1). Por lo tanto, la matriz que describe esta transformación es:

\[
\begin{pmatrix}
1 & -1 \\
1 & 1 \\
\end{pmatrix}
\]

Visualmente, esto corresponde a una rotación en sentido antihorario en 45 grados (o \ (\ pi/4 \) en radian) y un ligero estiramiento (por el factor \ (\ sqrt {2} \)).

Con esta información, veamos cómo se implementa esto con Quiver (tenga en cuenta que omito algún código de calderas como la escala del eje):

import numpy as np
import matplotlib.pyplot as plt
from matplotlib import cm

def quiver_plot_base_vectors(transformation_matrix: np.ndarray):
    # Define vectors
    basis_i = np.array([1, 0])
    basis_j = np.array([0, 1])
    i_transformed = transformation_matrix[:, 0]
    j_transformed = transformation_matrix[:, 1]
    
    # plot vectors with quiver-function
    cmap = cm.inferno
    fig, ax = plt.subplots()
    ax.quiver(0, 0, basis_i[0], basis_i[1], 
        color=cmap(0.2), 
        scale=1, 
        angles="xy", 
        scale_units="xy", 
        label="i", 
        alpha=0.3)
    ax.quiver(0, 0, i_transformed[0], i_transformed[1], 
        color=cmap(0.2), 
        scale=1,   
        angles="xy",  
        scale_units="xy", 
        label="i_transformed")
    ax.quiver(0, 0, basis_j[0], basis_j[1], 
        color=cmap(0.5), 
        scale=1, 
        angles="xy", 
        scale_units="xy", 
        label="j", 
        alpha=0.3)
    ax.quiver(0, 0, j_transformed[0], j_transformed[1], 
        color=cmap(0.5), 
        scale=1, 
        angles="xy", 
        scale_units="xy", 
        label="j_transformed")

if __name__ == "__main__":
    matrix = np.array([
        [1, -1],
        [1, 1]  
    ])
    quiver_plot_base_vectors(matrix)

Como puede ver, definimos una gráfica de carcaj por vector. Esto es solo para fines ilustrativos. Si observamos la firma de la función carcaj – quiver([X, Y], U, V, [C], /, **kwargs) – Podemos observar que U y V toman matrices numpy como entrada, lo cual es mejor que proporcionar valores escalares. Refactoremos esta función a usar solo una invocación de carcaj. Además, agregemos un vector V = (1.5, -0.5) para ver la transformación aplicada en él.

def quiver_plot(transformation_matrix: np.ndarray, vector: np.ndarray):
    # Define vectors
    basis_i = np.array([1, 0])
    basis_j = np.array([0, 1])
    i_transformed = transformation_matrix[:, 0]
    j_transformed = transformation_matrix[:, 1]
    vector_transformed = transformation_matrix @ vector
    U, V = np.stack(
        [
            basis_i, i_transformed,
            basis_j, j_transformed,
            vector, vector_transformed,
        ],
        axis=1)

    # Draw vectors
    color = np.array([.2, .2, .5, .5, .8, .8])
    alpha = np.array([.3, 1.0, .3, 1.0, .3, 1.0])
    cmap = cm.inferno
    fig, ax = plt.subplots()
    ax.quiver(np.zeros(6), np.zeros(6), U, V,
        color=cmap(color),
        alpha=alpha,
        scale=1,
        angles="xy",
        scale_units="xy",
    )

if __name__ == "__main__":
    matrix = np.sqrt(2) * np.array([
        [np.cos(np.pi / 4), np.cos(3 * np.pi / 4)],
        [np.sin(np.pi / 4), np.sin(3 * np.pi / 4)]
    ])
    vector = np.array([1.5, -0.5])
    quiver_plot(matrix, vector)

Esto es mucho más corto y conveniente que el primer ejemplo. Lo que hicimos aquí fue apilar cada vector que produce horizontalmente la siguiente matriz:

La primera fila corresponde al parámetro U de quiver y el segundo a V. mientras que las columnas contienen nuestros vectores, donde \ (\ vec {i} \) es el primer vector base, \ (\ vec {j} \) es el segundo y \ (\ vec {v} \) es nuestro vector personalizado. Los índices, B y A, representan antes y después (es decir, si La transformación lineal se aplica o no). Veamos la salida:

Transformación lineal de vectores base y \ (\ vec {v} \)
Imagen del autor

Echando un segundo vistazo al código, podría estar confundiendo lo que sucedió con nuestra matriz de transformación ordenada y simple, que se reafirmó a:

\[
{\scriptsize
M=\begin{pmatrix}
{1}&{-1}\\
{1}&{1}\\
\end{pmatrix}={\sqrt{2}}
\begin{pmatrix}
{\cos\left(\frac{1}{4}\pi\right)}&{\cos\left(\frac{3}{4}\pi\right)}\\
{\sin\left(\frac{1}{4}\pi\right)}&{\sin\left(\frac{3}{4}\pi\right)}\\
\end{pmatrix}
}
\]

La razón es que, a medida que avanzamos agregando animaciones, esta representación será útil. La multiplicación escalar por la raíz cuadrada de dos representa cuánto se estiran nuestros vectores, mientras que los elementos de la matriz se reescriben en notación trigonométrica para representar la rotación en el círculo unitario.

Vamos a animar

Las razones para agregar animaciones pueden incluir tramas más limpias, ya que podemos deshacernos de los vectores fantasmas y crear una experiencia más atractiva para las presentaciones. Para mejorar nuestra trama con animaciones, podemos permanecer en el ecosistema matplotlib utilizando el FuncAnimation() función de matplotlib.animation. La función toma los siguientes argumentos:

  • a matplotlib.figure.Figure objeto
  • una función de actualización
  • el número de cuadros

Para cada cuadro, se invoca la función de actualización produciendo una versión actualizada de la gráfica de tumba inicial. Para más detalles, consulte el documentación oficial de matplotlib.

Con esta información en mente, nuestra tarea es definir la lógica para implementar en la función de actualización. Comencemos simple con solo tres cuadros y nuestros vectores base. En el cuadro 0 estamos en el estado inicial. Mientras que en el último cuadro (cuadro 2) necesitamos llegar a la matriz reexpresada M. Por lo tanto, esperaríamos estar a mitad de camino en el cuadro 1. Debido a que los argumentos de \ (\ cos \) y \ (\ sin \) en M muestran a los radianes (es decir, cuán lejos hemos viajado en el círculo unitario), podemos dividirlos por dos para obtener nuestra rotación deseada. (El segundo vector obtiene un negativo \ (\ cos \), porque ahora estamos en el segundo cuadrante). Del mismo modo, debemos explicar el estiramiento, representado por el factor escalar. Hacemos esto calculando el cambio de magnitud, que es \ (\ sqrt {2} -1 \), y agregando la mitad de ese cambio a la escala inicial.

\[
{\scriptsize
\begin{aligned}
\text{Frame 0:} \quad &
\begin{pmatrix}
\cos(0) & \cos\left(\frac{\pi}{2}\right) \\
\sin(0) & \sin\left(\frac{\pi}{2}\right)
\end{pmatrix}
\\[1em]
\ Text {Frame 1:} \ quad &
s \ cdot \ begin {pMatrix}
\ cos \ left (\ frac {1} {2} \ cdot \ frac {\ pi} {4} \ right) & -\ cos \ left (\ frac {1} {2} \ cdot \ frac {3 \ pi} {4} \ right) \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ \\ \\\\ \\ \\ \\ \\ \\ \\) \\ \\ \\ \ \ \ \ \ \ \ \ \ right ight
\ sin \ izquierda (\ frac {1} {2} \ cdot \ frac {\ pi} {4} \ right) & \ sin \ left (\ frac {1} {2} \ cdot \ frac {3 \ pi} {4} \ right)
\ End {PMatrix}, \ quad \ text {con} S = 1 + \ frac {\ sqrt {2} – 1} {2}
\\[1em]
\ Text {Frame 2:} \ quad &
\ sqrt {2} \ cdot \ begin {pmatrix}
\ cos \ left (\ frac {\ pi} {4} \ right) & \ cos \ left (\ frac {3 \ pi} {4} \ right) \\ \\
\ sin \ izquierda (\ frac {\ pi} {4} \ right) & \ sin \ left (\ frac {3 \ pi} {4} \ right)
\ End {PMATRIX}
\ End {alineado}
}
\]

Las matrices describen dónde aterrizan los dos vectores base en cada cuadro
Gif por autor

Una advertencia a la explicación anterior: tiene el propósito de dar intuición a la idea de implementación y es cierto para los vectores base. Sin embargo, la implementación real contiene algunos pasos más, por ejemplo, algunas transformaciones con \ (\ arctan \) para obtener el comportamiento deseado para todos los vectores en el espacio bidimensional.

Entonces, inspeccionemos las partes principales de la implementación. El código completo se puede encontrar en mi github.

import numpy as np
import matplotlib.pyplot as plt
from matplotlib import animation
from matplotlib import cm

class AnimationPlotter:
[...]
def animate(self, filename='output/mat_transform.gif'):
        self.initialize_plot()
        anim = animation.FuncAnimation(
            self.fig,
            self.update_quiver,
            frames=self.frames + 1,
            init_func=self.init_quiver,
            blit=True,
        )
        anim.save(filename, writer='ffmpeg', fps=self.frames/2)
        plt.close()
   
if __name__ == "__main__":
    matrix = np.sqrt(2) * np.array([
        [np.cos(np.pi / 4), np.cos(3 * np.pi / 4)],
        [np.sin(np.pi / 4), np.sin(3 * np.pi / 4)]
       
    ])
    vector = np.array([1.5, -0.5]).reshape(2, 1)
    transformer = Transformer(matrix)
    animation_plotter = AnimationPlotter(transformer, vector)
    animation_plotter.animate()

El animate() El método pertenece a una clase personalizada, que se llama AnimationPlotter. Hace lo que ya aprendimos con las entradas según lo dispuesto anteriormente. La segunda clase en la escena es una clase personalizada Transformerque se encarga de calcular las transformaciones lineales y los vectores intermedios para cada cuadro. La lógica principal se encuentra dentro del AnimationPlotter.update_quiver() y Transformer.get_intermediate_vectors() métodos, y looks como sigue.

class AnimationPlotter:
    [...]
    def update_quiver(self, frame: int):
        incremented_vectors = self.transformer.get_intermediate_vectors(
            frame, self.frames
        )
        u = incremented_vectors[0]
        v = incremented_vectors[1]
        self.quiver_base.set_UVC(u, v)
        return self.quiver_base,

class Transformer:
    [...]
    def get_intermediate_vectors(self, frame: int, total_frames: int) -> np.ndarray:
         change_in_direction = self.transformed_directions - self.start_directions
         change_in_direction = np.arctan2(np.sin(change_in_direction), np.cos(change_in_direction))
         increment_direction = self.start_directions + change_in_direction * frame / total_frames
         increment_magnitude = self.start_magnitudes + (self.transformed_magnitudes - self.start_magnitudes) * frame / total_frames
         incremented_vectors = np.vstack([np.cos(increment_direction), np.sin(increment_direction)]) @ np.diag(increment_magnitude)
         return incremented_vectors

Lo que sucede aquí es que para cada cuadro se calculan los vectores intermedios. Esto se hace tomando la diferencia entre las direcciones Fin y Start (que representan ángulos vectoriales). El cambio en la dirección/ángulo se normaliza al rango \ ([-\pi, \pi]\) y agregado a la dirección inicial por una relación. La relación está determinada por los marcos actuales y totales. La magnitud se determina como ya se describió. Finalmente, el vector incrementado se calcula en función de la dirección y la magnitud y esto es lo que vemos en cada cuadro de la animación. Aumentar los marcos para decir 30 o 60 hace que la animación sea suave.

Animando la descomposición del valor singular (SVD)

Finalmente quiero mostrar cómo se creó la animación introductoria. Muestra cómo 4 vectores (cada uno para cada cuadrante) se transforman consecutivamente tres veces. De hecho, las tres transformaciones aplicadas corresponden a nuestra matriz de transformación bien conocida M de arriba, pero se descomponen a través de la descomposición del valor singular (SVD). Puede obtener o actualizar su conocimiento sobre SVD en este Gran e intuitivo artículo de TDS. O echar un vistazo aquí Si prefiere una lectura más centrada en matemáticas. Sin embargo, con numpy.linalg.svd() Es sencillo calcular el SVD de nuestra matriz M. Hacerlo resulta en la siguiente descomposición:

\[
{\scriptsize
\begin{align}
A \vec{v} &= U\Sigma V^T\vec{v} \\[1em]
\ sqrt {2} \ cdot \ begin {pmatrix}
\ cos \ left (\ frac {\ pi} {4} \ right) & \ cos \ left (\ frac {3 \ pi} {4} \ right) \\ \\
\ sin \ izquierda (\ frac {\ pi} {4} \ right) & \ sin \ left (\ frac {3 \ pi} {4} \ right)
\ end {PMATRIX} \ VEC {V} & =
\ Begin {PMatrix}
\ cos \ left (\ frac {3 \ pi} {4} \ right) & \ cos \ left (\ frac {3 \ pi} {4} \ right) \\ \\
\ sin \ izquierda (\ frac {-\ pi} {4} \ right) & \ sin \ left (\ frac {\ pi} {4} \ right)
\ End {PMATRIX}
\ Begin {PMatrix}
\ sqrt {2} & 0 \\
0 & \ sqrt {2}
\ End {PMATRIX}
\ Begin {PMatrix}
-1 y 0 \\
0 y 1
\ End {PMATRIX} \ VEC {V}
\ end {alinearse}
}
\]

Tenga en cuenta cómo el estiramiento por la raíz cuadrada se destila por la matriz media. La siguiente animación muestra cómo esto se ve en acción (o movimiento) para V = (1.5, -0.5).

Transformación con descomposición (izquierda) y transformación con matriz M (derecha) GIF por el autor

Al final, el vector púrpura \ (\ vec {v} \) llega a su posición determinada en ambos casos.

Conclusión

Para envolverlo, podemos usar quiver() para mostrar vectores en el espacio 2D y, con la ayuda de matplotlib.animation.FuncAnimation(), Agregue animaciones atractivas en la parte superior. Esto da como resultado visualizaciones claras de transformaciones lineales que puede usar, por ejemplo, para demostrar la mecánica subyacente de sus algoritmos de aprendizaje automático. Siéntase libre de desembolsar mi repositorio e implementar sus propias visualizaciones. ¡Espero que hayas disfrutado de la lectura!