daikiad's blog

Visualize Mathematics Beautifully with Manim!

Published on 2024-05-25
tags: [ manim math linear-algebra python ]

Understanding mathematics and physics deeply requires both a rigorous theoretical understanding through mathematical equations and an intuitive grasp via visual imagery. The human brain excels at processing visual information effectively, allowing us to gain deeper insights by visualizing abstract concepts.

Recently, I came across a fascinating Python library called manim, created by Grant Sanderson, well-known for his 3Blue1Brown YouTube channel. This library enables users to create beautiful animations of mathematical concepts using simple Python code. In this post, I will demonstrate how manim can be used to visualize the concept of eigendecomposition.

Visualizing Eigendecomposition

Before diving into the actual code and the generated video, let’s review eigendecomposition. Given an n × n square matrix $A$, the scalar $\lambda$ and vector $\boldsymbol x$ that satisfy the following equation are called the eigenvalue and eigenvector of matrix $A$, respectively:

\[A\boldsymbol v = \lambda \boldsymbol v\]

When there exist n linearly independent eigenvectors satisfying this equation, matrix $A$ can be diagonalized as follows:

\[\begin{align} A &= P\Lambda P^{-1} \end{align}\]

where

\[\begin{align} P &= \begin{bmatrix} \boldsymbol v_1 ,\ldots, \boldsymbol v_n \end{bmatrix}\\ \Lambda &= \begin{bmatrix} \lambda_1 & & \\ & \ddots & \\ &&\lambda_n \end{bmatrix} \end{align}\]

Eigendecomposition can be interpreted as a transformation that shrinks the space in the direction of each eigenvector by the magnitude of the corresponding eigenvalue. For example, let’s consider the following matrix $A$:

\[A = \begin{bmatrix} 2 & 1\\ 1 & 1 \end{bmatrix}\]

The eigenvalues of this matrix are $\lambda_1 = 2.62$ and $\lambda_2 = 0.38$, and the corresponding eigenvectors are:

\[\begin{align} \boldsymbol v_1 &= \begin{bmatrix} 0.85\\ 0.53 \end{bmatrix}\\ \boldsymbol v_2 &= \begin{bmatrix} -0.53\\ 0.85 \end{bmatrix} \end{align}\]

Note that for simplicity, I have truncated the values to two decimal places. Through eigendecomposition, matrix $A$ can be decomposed as follows:

\[\begin{align} A &= P\Lambda P^{-1} \\ &= \begin{bmatrix} 0.85 & -0.53\\ 0.53 & 0.85 \end{bmatrix} \begin{bmatrix} 2.62 & 0 \\ 0 & 0.38 \end{bmatrix} \begin{bmatrix} 0.85 & 0.53\\ -0.53 & 0.85 \end{bmatrix} \end{align}\]

This decomposition can be understood as a composition of three linear transformations:

  1. The matrix $P^{-1}$ transforms the original space such that the eigenvectors $\boldsymbol v_1, \boldsymbol v_2$ align with the original standard basis vectors $\boldsymbol e_1, \boldsymbol e_2$. In other words, $P^{-1}$ deforms the original space so that the eigenvectors point in the direction of the axes of the orthogonal coordinate system.
  2. The diagonal matrix $\Lambda$ represents a linear transformation that shrinks the space in each axis direction by the magnitude of the corresponding eigenvalue, in the transformed space where the eigenvectors are the standard basis.
  3. The matrix $P$ represents the linear transformation that maps the space back from the transformed space, where the eigenvectors are the standard basis, to the original space.

Now, let’s visualize the eigendecomposition using manim.

Visualization Code

The following is the Python code used for the visualization in this post. It visualizes the eigendecomposition of the matrix \(A = \begin{bmatrix} 2 & 1\\ 1 & 1 \end{bmatrix}\) .

This post is just a brief introduction to manim, so I will omit a detailed explanation of the code. For now, just remember that you can create animations by writing Python code like the one shown.

from manim import *
from numpy import linalg as LA
class EigenDecomposition(LinearTransformationScene):

    def construct(self):

        A = np.array([
            [2., 1,],
            [1, 1,]
        ])

        eigenvalues = LA.eig(A).eigenvalues
        eigenvectors = LA.eig(A).eigenvectors
        P = LA.eig(A).eigenvectors
        Lambda = np.diag(LA.eig(A).eigenvalues)
        P_inv = LA.inv(P)


        def matrix_to_tex(matrix):
            tex_str = "\\begin{bmatrix}\n"
            for i in range(matrix.shape[0]):
                for j in range(matrix.shape[1]):
                    tex_str += f"{matrix[i, j]:.2f}"
                    if j < matrix.shape[1]-1:
                        tex_str += " & "
                if i < matrix.shape[0]-1:
                    tex_str += "\\\\\\"
                tex_str += "\n"
            tex_str += "\\end{bmatrix}"
            return tex_str
                

        texts = []
        text_A = MathTex("A = "+matrix_to_tex(A)).to_corner(UL)
        
        self.play(Write(text_A))
        texts.append(text_A)
        frameboxes = []
        text_eigvals = []
        text_eigvecs = []
        for i, eigvec in enumerate(eigenvectors.transpose()):
            text = MathTex(
                f"\\lambda_{i} = ",
                f"{eigenvalues[i]:.2f}",
                f", \\boldsymbol v_{i} = ",
                matrix_to_tex(eigenvectors[:, i].reshape(-1, 1))
            ).next_to(texts[-1], DOWN)
            text.align_to(texts[-1], LEFT)
            text_eigvals.append(text[1])
            text_eigvecs.append(text[3])

            self.play(Write(text))

            texts.append(text)
            frameboxes.append(SurroundingRectangle(text[3]))


        self.wait(1)



        for i, eigvec in enumerate(eigenvectors.transpose()):
            if i==0:
                self.play(Create(frameboxes[i]))
            else:
                self.play(ReplacementTransform(frameboxes[i-1], frameboxes[i]))
            self.play(Create(Line([eigvec[0]*-10, eigvec[1]*-10, 0], [eigvec[0]*10, eigvec[1]*10, 0], color=YELLOW, stroke_width=2)))
            self.add_vector(eigvec, color=WHITE)



        self.play(FadeOut(frameboxes[-1]))


        P_equals = MathTex("P = ").next_to(texts[-1], DOWN, buff=1).to_corner(LEFT)
        matrix_P = DecimalMatrix(P, element_to_mobject_config={"num_decimal_places":2},).next_to(P_equals)

        Lambda_equals = MathTex("\\Lambda = ").next_to(matrix_P, RIGHT,)
        matrix_Lambda = DecimalMatrix(Lambda, element_to_mobject_config={"num_decimal_places":2},).next_to(Lambda_equals)
        
        
        P_inv_equals = MathTex("P^{-1} = ").next_to(matrix_Lambda, RIGHT)
        matrix_P_inv = DecimalMatrix(P_inv, element_to_mobject_config={"num_decimal_places":2}).next_to(P_inv_equals)

        self.play(Write(P_equals))
        self.play(text_eigvecs[0].animate.set_color(YELLOW))
        self.play(Transform(text_eigvecs[0], matrix_P.get_columns()[0]))
        self.play(text_eigvecs[1].animate.set_color(YELLOW))
        self.play(Transform(text_eigvecs[1], matrix_P.get_columns()[1]))
        self.play(Write(matrix_P))
        
        self.play(Write(Lambda_equals))
        self.play(text_eigvals[0].animate.set_color(YELLOW), text_eigvals[1].animate.set_color(YELLOW))
        self.play(Transform(text_eigvals[0], matrix_Lambda.get_entries()[0]), Transform(text_eigvals[1], matrix_Lambda.get_entries()[3]))
        self.play(Write(matrix_Lambda))


        self.play(Write(P_inv_equals), Write(matrix_P_inv))

        self.play(FadeOut(*texts))

        eig_group = VGroup(Lambda_equals, matrix_Lambda, P_equals, matrix_P, P_inv_equals, matrix_P_inv)
        bg_eig_group = BackgroundRectangle(eig_group, color=BLACK, fill_opacity=0.7)
        eig_group = VGroup(bg_eig_group, eig_group)
        self.play(eig_group.animate.to_edge(UP))

        self.wait(1)


        eigdecomp = MathTex("A = ", "P", "\\Lambda", "P^{-1}").next_to(eig_group, DOWN).to_edge(LEFT)
        bg_eigdecomp = BackgroundRectangle(eigdecomp, color=BLACK, fill_opacity=0.7)
        eigdecomp_group = VGroup(bg_eigdecomp, eigdecomp)

        P_box = SurroundingRectangle(eigdecomp[1], color=YELLOW)
        Lambda_box = SurroundingRectangle(eigdecomp[2], color=YELLOW)
        P_inv_box = SurroundingRectangle(eigdecomp[3], color=YELLOW)

        P_elem_box = SurroundingRectangle(matrix_P, color=YELLOW)
        Lambda_elem_box = SurroundingRectangle(matrix_Lambda, color=YELLOW)
        P_inv_elem_box = SurroundingRectangle(matrix_P_inv, color=YELLOW)
        
        self.add_background_mobject(eigdecomp)
        self.play(Write(eigdecomp_group))
        self.moving_mobjects = []


   

        self.play(Create(P_inv_box), Create(P_inv_elem_box))
        self.moving_mobjects = []
        self.apply_matrix(P_inv)
        self.moving_mobjects = []
        self.wait(0.5)

        self.play(ReplacementTransform(P_inv_box, Lambda_box), ReplacementTransform(P_inv_elem_box, Lambda_elem_box))
        self.moving_mobjects = []
        self.apply_matrix(Lambda)
        self.moving_mobjects = []
        self.wait(0.5)

        self.play(ReplacementTransform(Lambda_box, P_box), ReplacementTransform(Lambda_elem_box, P_elem_box))
        self.moving_mobjects = []
        self.apply_matrix(P)
        self.wait(0.5)
        self.play(FadeOut(P_box), FadeOut(P_elem_box))
        
        self.wait()

If you save the above code in a file named eigendecomposition.py and run the following command in the shell, an mp4 file will be generated

$ manim -pql eigendecomposition.py EigenDecomposition

Generated Animation

Here is the generated video. You can see the process of applying $P^{-1}, \Lambda, P$ obtained from the eigendecomposition of $A$ in order.

Conclusion

In this post, I shared my experience with manim, a Python library for animating mathematical concepts, and demonstrated how it can be used to visually understand eigendecomposition. By combining theoretical understanding with visual intuition, we can deepen our understanding and appreciation of mathematics.

I look forward to trying out visualizations of other mathematical concepts using manim in the future. Until next time!