Math Linear Algebra
Math Linear Algebra
Linear Algebra is the branch of mathematics that studies vector spaces and linear
transformations between vector spaces, such as rotating a shape, scaling it up or down,
translating it (i.e. moving it), etc.
Machine Learning relies heavily on Linear Algebra, so it is essential to understand what vectors
and matrices are, what operations you can perform with them, and how they can be useful.
Vectors
Definition
A vector is a quantity defined by a magnitude and a direction. For example, a rocket's velocity is a
3-dimensional vector: its magnitude is the speed of the rocket, and its direction is (hopefully) up.
A vector can be represented by an array of numbers called scalars. Each scalar corresponds to
the magnitude of the vector with regards to each dimension.
For example, say the rocket is going up at a slight angle: it has a vertical speed of 5,000 m/s, and
also a slight speed towards the East at 10 m/s, and a slight speed towards the North at 50 m/s.
The rocket's velocity may be represented by the following vector:
( )
10
velocity ¿ 50
5000
Note: by convention vectors are generally presented in the form of columns. Also, vector names
are usually lowercase to distinguish them from matrices (which we will discuss below) and in
bold (when possible) to distinguish them from simple scalar values such as
m e t e r s p e r s e c o n d=5026.
A list of N numbers may also represent the coordinates of a point in an N-dimensional space, so
it is quite frequent to represent vectors as simple points instead of arrows. A vector with 1
element may be represented as an arrow or a point on an axis, a vector with 2 elements is an
arrow or a point on a plane, a vector with 3 elements is an arrow or a point in space, and a vector
with N elements is an arrow or a point in an N-dimensional space… which most people find hard
to imagine.
Purpose
Vectors have many purposes in Machine Learning, most notably to represent observations and
predictions. For example, say we built a Machine Learning system to classify videos into 3
categories (good, spam, clickbait) based on what we know about them. For each video, we
would have a vector representing what we know about it, such as:
()
10.5
5.2
video ¿
3.25
7.0
This vector could represent a video that lasts 10.5 minutes, but only 5.2% viewers watch for
more than a minute, it gets 3.25 views per day on average, and it was flagged 7 times as spam.
As you can see, each axis may have a different meaning.
Based on this vector our Machine Learning system may predict that there is an 80% probability
that it is a spam video, 18% that it is clickbait, and 2% that it is a good video. This could be
represented as the following vector:
( )
0.80
class_probabilities ¿ 0.18
0.02
Vectors in python
In python, a vector can be represented in many ways, the simplest being a regular python list of
numbers:
Since we plan to do quite a lot of scientific calculations, it is much better to use NumPy's
ndarray, which provides a lot of convenient and optimized implementations of essential
mathematical operations on vectors (for more details about NumPy, check out the NumPy
tutorial). For example:
import numpy as np
video.size
Note that indices in mathematics generally start at 1, but in programming they usually start at 0.
So to access video 3 programmatically, we would write:
video[2] # 3rd element
3.25
Plotting vectors
To plot vectors we will use matplotlib, so let's start by importing it (for details about matplotlib,
check the matplotlib tutorial):
2D vectors
Let's create a couple of very simple 2D vectors to plot:
u = np.array([2, 5])
v = np.array([3, 1])
These vectors each have 2 elements, so they can easily be represented graphically on a 2D
graph, for example as points:
plot_vector2d(u, color="r")
plot_vector2d(v, color="b")
plt.axis([0, 9, 0, 6])
plt.grid()
plt.show()
3D vectors
Plotting 3D vectors is also relatively straightforward. First, let's create two 3D vectors:
a = np.array([1, 2, 8])
b = np.array([5, 6, 3])
It is a bit hard to visualize exactly where in space these two points are, so let's add vertical lines.
We'll create a small convenience function to plot a list of 3d vectors with vertical lines attached:
‖u )= √∑ u
i
i
2
That's the square root of the sum of all the squares of the components of u. We could
1
implement this easily in pure python, recalling that √ x=x 2
def vector_norm(vector):
squares = [element**2 for element in vector]
return sum(squares)**0.5
|| [2 5] || =
5.385164807134504
However, it is much more efficient to use NumPy's norm function, available in the linalg
(Linear Algebra) module:
import numpy.linalg as LA
LA.norm(u)
5.385164807134504
Let's plot a little diagram to confirm that the length of vector u is indeed ≈ 5.4 :
radius = LA.norm(u)
plt.gca().add_artist(plt.Circle((0,0), radius, color="#DDDDDD"))
plot_vector2d(u, color="red")
plt.axis([0, 8.7, 0, 6])
plt.gca().set_aspect("equal")
plt.grid()
plt.show()
Addition
Vectors of same size can be added together. Addition is performed elementwise:
print(" ", u)
print("+", v)
print("-"*10)
u + v
[2 5]
+ [3 1]
----------
array([5, 6])
Let's look at what vector addition looks like graphically:
plot_vector2d(u, color="r")
plot_vector2d(v, color="b")
plot_vector2d(v, origin=u, color="b", linestyle="dotted")
plot_vector2d(u, origin=v, color="r", linestyle="dotted")
plot_vector2d(u+v, color="g")
plt.axis([0, 9, 0, 7])
plt.gca().set_aspect("equal")
plt.text(0.7, 3, "u", color="r", fontsize=18)
plt.text(4, 3, "u", color="r", fontsize=18)
plt.text(1.8, 0.2, "v", color="b", fontsize=18)
plt.text(3.1, 5.6, "v", color="b", fontsize=18)
plt.text(2.4, 2.5, "u+v", color="g", fontsize=18)
plt.grid()
plt.show()
Vector addition is commutative, meaning that u+ v=v +u . You can see it on the previous image:
following u then v leads to the same point as following v then u.
If you have a shape defined by a number of points (vectors), and you add a vector v to all of these
points, then the whole shape gets shifted by v . This is called a geometric translation:
t1 = np.array([2, 0.25])
t2 = np.array([2.5, 3.5])
t3 = np.array([1, 2])
x_coords, y_coords = zip(t1, t2, t3, t1)
plt.plot(x_coords, y_coords, "c--", x_coords, y_coords, "co")
t1b = t1 + v
t2b = t2 + v
t3b = t3 + v
plt.axis([0, 6, 0, 5])
plt.gca().set_aspect("equal")
plt.grid()
plt.show()
Multiplication by a scalar
Vectors can be multiplied by scalars. All elements in the vector are multiplied by that number,
for example:
print("1.5 *", u, "=")
1.5 * u
1.5 * [2 5] =
array([3. , 7.5])
Graphically, scalar multiplication results in changing the scale of a figure, hence the name scalar.
The distance from the origin (the point at coordinates equal to zero) is also multiplied by the
scalar. For example, let's scale up by a factor of k = 2.5:
k = 2.5
t1c = k * t1
t2c = k * t2
t3c = k * t3
plot_vector2d(t1, color="r")
plot_vector2d(t2, color="r")
plot_vector2d(t3, color="r")
plt.axis([0, 9, 0, 9])
plt.gca().set_aspect("equal")
plt.grid()
plt.show()
As you might guess, dividing a vector by a scalar is equivalent to multiplying by its multiplicative
inverse (reciprocal):
u 1
= ×u
λ λ
Scalar multiplication is commutative: λ × u=u × λ .
It is also associative: λ 1 × ( λ2 × u )= ( λ1 × λ 2) × u .
u ⋅ v=‖u ) ×‖v ) × c o s ( θ )
where θ is the angle between u and v .
u ⋅ v=∑ ui × v i
i
In python
The dot product is pretty simple to implement:
dot_product(u, v)
11
But a much more efficient implementation is provided by NumPy with the np.dot() function:
np.dot(u, v)
11
u.dot(v)
11
Caution: the * operator will perform an elementwise multiplication, NOT a dot product:
print(" ",u)
print("* ",v, "(NOT a dot product)")
print("-"*10)
u * v
[2 5]
* [3 1] (NOT a dot product)
----------
array([6, 5])
Main properties
• The dot product is commutative: u ⋅ v=v ⋅ u.
• The dot product is only defined between two vectors, not between a scalar and a vector.
This means that we cannot chain dot products: for example, the expression u ⋅ v ⋅ w is not
defined since u ⋅ v is a scalar and w is a vector.
• This also means that the dot product is NOT associative: ( u ⋅ v ) ⋅ w ≠ u ⋅ ( v ⋅w ) since
neither are defined.
• However, the dot product is associative with regards to scalar multiplication:
λ × ( u ⋅ v )=( λ× u ) ⋅v =u ⋅ ( λ × v )
• Finally, the dot product is distributive over addition of vectors: u ⋅ ( v+ w )=u ⋅v + u ⋅ w .
θ=arccos
( u⋅v
‖u ) ×‖v ) )
π
Note that if u ⋅ v=0, it follows that θ= . In other words, if the dot product of two non-null
2
vectors is zero, it means that they are orthogonal.
Let's use this formula to calculate the angle between u and v (in radians):
def vector_angle(u, v):
cos_theta = u.dot(v) / LA.norm(u) / LA.norm(v)
return np.arccos(cos_theta.clip(-1, 1))
theta = vector_angle(u, v)
print("Angle =", theta, "radians")
print(" =", theta * 180 / np.pi, "degrees")
Note: due to small floating point errors, cos_theta may be very slightly outside the [ −1 , 1 )
interval, which would make arccos fail. This is why we clipped the value within the range, using
NumPy's clip function.
proju v=( v ⋅ u^ ) × u^
u_normalized = u / LA.norm(u)
proj = v.dot(u_normalized) * u_normalized
plot_vector2d(u, color="r")
plot_vector2d(v, color="b")
plt.axis([0, 8, 0, 5.5])
plt.gca().set_aspect("equal")
plt.grid()
plt.show()
Matrices
A matrix is a rectangular array of scalars (i.e. any number: integer, real or complex) arranged in
rows and columns, for example:
You can also think of a matrix as a list of vectors: the previous matrix contains either 2 horizontal
3D vectors or 3 vertical 2D vectors.
Matrices are convenient and very efficient to run operations on many vectors at a time. We will
also see that they are great at representing and performing linear transformations such
rotations, translations and scaling.
Matrices in python
In python, a matrix can be represented in various ways. The simplest is just a list of python lists:
[
[10, 20, 30],
[40, 50, 60]
]
A much more efficient way is to use the NumPy library which provides optimized
implementations of many matrix operations:
A = np.array([
[10,20,30],
[40,50,60]
])
A
In the rest of this tutorial, we will assume that we are using NumPy arrays (type ndarray) to
represent matrices.
Size
The size of a matrix is defined by its number of rows and number of columns. It is noted
r o w s × c o lu m n s. For example, the matrix A above is an example of a 2 ×3 matrix: 2 rows, 3
columns. Caution: a 3 ×2 matrix would have 3 rows and 2 columns.
A.shape
(2, 3)
Caution: the size attribute represents the number of elements in the ndarray, not the
matrix's size:
A.size
Element indexing
The number located in the i t h row, and j t h column of a matrix X is sometimes noted X i , j or X i j,
but there is no standard notation, so people often prefer to explicitly name the elements, like
this: "let X =( x i , j) 1≤ i ≤m ,1 ≤ j ≤n ". This means that X is equal to:
[ )
x 1 ,1 x1 , 2 x 1 ,3 ⋯ x 1 ,n
x 2 ,1 x 2, 2 x 2 ,3 ⋯ x 2 ,n
X = x 3 ,1 x3 , 2 x 3 ,3 ⋯ x 3 ,n
⋮ ⋮ ⋮ ⋱ ⋮
x m ,1 xm ,2 x m ,3 ⋯ x m ,n
However, in this notebook we will use the X i , j notation, as it matches fairly well NumPy's
notation. Note that in math indices generally start at 1, but in programming they usually start at
0. So to access A2 , 3 programmatically, we need to write this:
60
The i t h row vector is sometimes noted M i or M i ,∗¿¿, but again there is no standard notation so
people often prefer to explicitly define their own names, for example: "let x❑i be the i t h row
vector of matrix X ". We will use the M i ,∗¿¿, for the same reason as above. For example, to access
A2 ,∗¿¿ (i.e. A 's 2nd row vector):
array([30, 60])
Note that the result is actually a one-dimensional NumPy array: there is no such thing as a
vertical or horizontal one-dimensional array. If you need to actually represent a row vector as a
one-row matrix (i.e. a 2D NumPy array), or a column vector as a one-column matrix, then you
need to use a slice instead of an integer when accessing the row or column, for example:
array([[30],
[60]])
An upper triangular matrix is a special kind of square matrix where all the elements below the
main diagonal (top-left to bottom-right) are zero, for example:
\begin{bmatrix} 4 & 9 & 2 \ 0 & 5 & 7 \ 0 & 0 & 6 \end{bmatrix}
Similarly, a lower triangular matrix is a square matrix where all elements above the main
diagonal are zero, for example:
A matrix that is both upper and lower triangular is called a diagonal matrix, for example:
np.diag([4, 5, 6])
array([[4, 0, 0],
[0, 5, 0],
[0, 0, 6]])
If you pass a matrix to the diag function, it will happily extract the diagonal values:
D = np.array([
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
])
np.diag(D)
array([1, 5, 9])
Finally, the identity matrix of size n , noted I n, is a diagonal matrix of size n × n with 1's in the
main diagonal, for example I 3:
Numpy's eye function returns the identity matrix of the desired size:
np.eye(3)
The identity matrix is often noted simply I (instead of I n) when its size is clear given the context.
It is called the identity matrix because multiplying a matrix with it leaves the matrix unchanged
as we will see below.
Adding matrices
If two matrices Q and R have the same size m ×n, they can be added together. Addition is
performed elementwise: the result is also an m ×n matrix S where each element is the sum of
the elements at the corresponding position: Si , j=Qi , j + Ri , j
[ )
Q11 + R11 Q 12+ R12 Q13+ R 13 ⋯ Q1 n+ R 1 n
Q21+ R21 Q 22+ R22 Q23+ R 23 ⋯ Q2 n+ R 2 n
S= Q31+ R31 Q 32+ R32 Q33+ R 33 ⋯ Q3 n+ R 3 n
⋮ ⋮ ⋮ ⋱ ⋮
Q m 1+ Rm 1 Q m 2 + Rm 2 Q m 3+ R m 3 ⋯ Q m n+ R m n
array([[1, 2, 3],
[4, 5, 6]])
A + B
B + A
A + (B + C)
(A + B) + C
array([[111, 222, 333],
[444, 555, 666]])
Scalar multiplication
A matrix M can be multiplied by a scalar λ . The result is noted λ M , and it is a matrix of the same
size as M with all elements multiplied by λ :
[ )
λ × M 11 λ × M 12 λ × M 13 ⋯ λ × M 1n
λ × M 21 λ × M 22 λ × M 23 ⋯ λ × M 2n
λ M = λ × M 31 λ × M 32 λ × M 33 ⋯ λ × M 3 n
⋮ ⋮ ⋮ ⋱ ⋮
λ × M m1 λ × M m2 λ × M m3 ⋯ λ × M m n
( λ M )i , j=λ ( M )i , j
In NumPy, simply use the * operator to multiply a matrix by a scalar. For example:
2 * A
Scalar multiplication is also defined on the right-hand side, and gives the same result: M λ= λ M .
For example:
A * 2
It is also associative, meaning that α ( β M )=( α × β ) M , where α and β are scalars. For example:
2 * (3 * A)
(2 * 3) * A
2 * A + 2 * B
Matrix multiplication
So far, matrix operations have been rather intuitive. But multiplying matrices is a bit more
involved.
The element at position i , j in the resulting matrix is the sum of the products of elements in row
i of matrix Q by the elements in column j of matrix R .
[ )
Q11 R11 + Q12 R21+ ⋯+Q1 n Rn 1 Q11 R 12+Q12 R 22+⋯+Q 1n Rn 2 ⋯ Q11 R1 q +Q12 R2 q +⋯+Q1 n Rn q
Q R +Q R +⋯+Q2 n R n1 Q 21 R12+Q 22 R22+ ⋯+ Q2 n Rn 2 ⋯ Q21 R1 q +Q22 R 2q +⋯+ Q2 n Rn q
P= 21 11 22 21
⋮ ⋮ ⋱ ⋮
Qm 1 R11 + Qm 2 R21 +⋯+Qm n Rn 1 Qm 1 R 12+Q m 2 R22 +⋯+Qm n R n 2 ⋯ Qm 1 R1 q +Qm 2 R 2 q+ ⋯+Qm n R n q
You may notice that each element Pi , j is the dot product of the row vector Q i ,∗¿¿ and the column
vector R¿ , j:
Pi , j=Qi ,∗¿⋅ R ¿, j ¿
[ )[
2 3 5 7
E=A D=
[ 10 20 30
40 50 60 )
11 13 17 19 =
23 29 31 37
930 1160 1320 1560
2010 2510 2910 3450 )
D = np.array([
[ 2, 3, 5, 7],
[11, 13, 17, 19],
[23, 29, 31, 37]
])
E = np.matmul(A, D)
E
Python 3.5 introduced the @ infix operator for matrix multiplication, and NumPy 1.10 added
support for it. A @ D is equivalent to np.matmul(A, D):
A @ D
The @ operator also works for vectors. u @ v computes the dot product of u and v:
u @ v
11
Let's check this result by looking at one element, just to be sure. To calculate E2 , 3 for example,
we need to multiply elements in A 's 2n d row by elements in D 's 3r d column, and sum up these
products:
2910
2910
Looks good! You can check the other elements until you get used to the algorithm.
try:
D @ A
except ValueError as e:
print("ValueError:", e)
This illustrates the fact that matrix multiplication is NOT commutative: in general Q R≠ R Q .
In fact, Q R and R Q are only both defined if Q has size m ×n and R has size n × m. Let's look at
an example where both are defined and show that they are (in general) NOT equal:
F = np.array([
[5,2],
[4,1],
[9,3]
])
A @ F
array([[400, 130],
[940, 310]])
F @ A
On the other hand, matrix multiplication is associative, meaning that Q ( R S )=( Q R ) S . Let's
create a 4 ×5 matrix G to illustrate this:
G = np.array([
[8, 7, 4, 2, 5],
[2, 5, 1, 0, 5],
[9, 11, 17, 21, 0],
[0, 1, 0, 1, 2]])
(A @ D) @ G # (AD)G
A @ (D @ G) # A(DG)
It is also distributive over addition of matrices, meaning that ( Q+ R ) S=Q S+ R S. For example:
(A + B) @ D
A @ D + B @ D
The product of a matrix M by the identity matrix (of matching size) results in the same matrix M .
More formally, if M is an m ×n matrix, then:
M I n=I m M =M
This is generally written more concisely (since the size of the identity matrices is unambiguous
given the context):
M I =I M =M
For example:
A @ np.eye(3)
np.eye(2) @ A
Matrix transpose
The transpose of a matrix M is a matrix noted M T such that the i t h row in M T is equal to the i t h
column in M :
A.T
array([[10, 40],
[20, 50],
[30, 60]])
As you might expect, transposing a matrix twice returns the original matrix:
A.T.T
(A + B).T
array([[11, 44],
[22, 55],
[33, 66]])
A.T + B.T
array([[11, 44],
[22, 55],
[33, 66]])
(A @ D).T
D.T @ A.T
\begin{bmatrix} 17 & 22 & 27 & 49 \ 22 & 29 & 36 & 0 \ 27 & 36 & 45 & 2 \ 49 & 0 & 2 & 99 \
end{bmatrix}
The product of a matrix by its transpose is always a symmetric matrix, for example:
D @ D.T
array([2, 5])
u.T
array([2, 5])
We want to convert u into a row vector before transposing it. There are a few ways to do this:
u_row = np.array([u])
u_row
array([[2, 5]])
Notice the extra square brackets: this is a 2D array with just one row (i.e. a 1 ×2 matrix). In other
words, it really is a row vector.
u[np.newaxis, :]
array([[2, 5]])
This is quite explicit: we are asking for a new vertical axis, keeping the existing data as the
horizontal axis.
u[np.newaxis]
array([[2, 5]])
u[None]
array([[2, 5]])
This is the shortest version, but you probably want to avoid it because it is unclear. The reason it
works is that np.newaxis is actually equal to None, so this is equivalent to the previous
version.
u_row.T
array([[2],
[5]])
Rather than creating a row vector then transposing it, it is also possible to convert a 1D array
directly into a column vector:
u[:, np.newaxis]
array([[2],
[5]])
Plotting a matrix
We have already seen that vectors can be represented as points or arrows in N-dimensional
space. Is there a good graphical representation of matrices? Well you can simply see a matrix as
a list of vectors, so plotting a matrix results in many points or arrows. For example, let's create a
2 × 4 matrix P and plot it as points:
P = np.array([
[3.0, 4.0, 1.0, 4.6],
[0.2, 3.5, 2.0, 0.5]
])
x_coords_P, y_coords_P = P
plt.scatter(x_coords_P, y_coords_P)
plt.axis([0, 5, 0, 4])
plt.gca().set_aspect("equal")
plt.grid()
plt.show()
Of course, we could also have stored the same 4 vectors as row vectors instead of column
vectors, resulting in a 4 ×2 matrix (the transpose of P, in fact). It is really an arbitrary choice.
Since the vectors are ordered, you can see the matrix as a path and represent it with connected
dots:
H = np.array([
[ 0.5, -0.2, 0.2, -0.1],
[ 0.4, 0.4, 1.5, 0.6]
])
P_moved = P + H
plt.gca().add_artist(Polygon(P.T, alpha=0.2))
plt.gca().add_artist(Polygon(P_moved.T, alpha=0.3, color="r"))
for vector, origin in zip(H.T, P.T):
plot_vector2d(vector, origin=origin)
plt.axis([0, 5, 0, 4])
plt.gca().set_aspect("equal")
plt.grid()
plt.show()
If we add a matrix full of identical vectors, we get a simple geometric translation:
H2 = np.array([
[-0.5, -0.5, -0.5, -0.5],
[ 0.4, 0.4, 0.4, 0.4]
])
P_translated = P + H2
plt.gca().add_artist(Polygon(P.T, alpha=0.2))
plt.gca().add_artist(Polygon(P_translated.T, alpha=0.3, color="r"))
for vector, origin in zip(H2.T, P.T):
plot_vector2d(vector, origin=origin)
plt.axis([0, 5, 0, 4])
plt.gca().set_aspect("equal")
plt.grid()
plt.show()
Although matrices can only be added together if they have the same size, NumPy allows adding
a row vector or a column vector to a matrix: this is called broadcasting and is explained in further
details in the NumPy tutorial. We could have obtained the same result as above with:
Scalar multiplication
Multiplying a matrix by a scalar results in all its vectors being multiplied by that scalar, so
unsurprisingly, the geometric result is a rescaling of the entire figure. For example, let's rescale
our polygon by a factor of 60% (zooming out, centered on the origin):
P_rescaled = 0.60 * P
plot_transformation(P, P_rescaled, "$P$", "$0.6 P$", arrows=True)
plt.show()
Let's start simple, by defining a 1 ×2 matrix U =[ 1 0 ) . This row vector is just the horizontal unit
vector.
U = np.array([[1, 0]])
U @ P
array([[3. , 4. , 1. , 4.6]])
These are the horizontal coordinates of the vectors in P . In other words, we just projected P
onto the horizontal axis:
def plot_projection(U, P):
U_P = U @ P
axis_end = 100 * U
plot_vector2d(axis_end[0], color="black")
plt.gca().add_artist(Polygon(P.T, alpha=0.2))
for vector, proj_coordinate in zip(P.T, U_P.T):
proj_point = proj_coordinate * U
plt.plot(proj_point[0][0], proj_point[0][1], "ro", zorder=10)
plt.plot([vector[0], proj_point[0][0]], [vector[1],
proj_point[0][1]],
"r--", zorder=10)
plt.axis([0, 5, 0, 4])
plt.gca().set_aspect("equal")
plt.grid()
plt.show()
plot_projection(U, P)
We can actually project on any other axis by just replacing U with any other unit vector. For
example, let's project on the axis that is at a 30° angle above the horizontal axis:
plot_projection(U_30, P)
Good! Remember that the dot product of a unit vector and a matrix basically performs a
projection on an axis and gives us the coordinates of the resulting points on that axis.
V=
[ cos ( 30 ° ) sin ( 30 ° )
cos ( 120 ° ) sin ( 120 ° ) )
angle120 = 120 * np.pi / 180
V = np.array([
[np.cos(angle30), np.sin(angle30)],
[np.cos(angle120), np.sin(angle120)]
])
V
V @ P
P_rotated = V @ P
plot_transformation(P, P_rotated, "$P$", "$VP$", [-2, 6, -2, 4],
arrows=True)
plt.show()
()
x
u= y
z
F=
[ ad b c
e f )
Now, to compute f ( u ) we can simply do a matrix multiplication:
f ( u )=F u
F G=[ f ( u1 ) f ( u2 ) ⋯ f ( u q ) )
To summarize, the matrix on the left-hand side of a dot product specifies what linear
transformation to apply to the right-hand side vectors. We have already shown that this can be
used to perform projections and rotations, but any other linear transformation is possible. For
example, here is a transformation known as a shear mapping:
F_shear = np.array([
[1, 1.5],
[0, 1]
])
plot_transformation(P, F_shear @ P, "$P$", "$F_{shear} P$",
axis=[0, 10, 0, 7])
plt.show()
Let's look at how this transformation affects the unit square:
Square = np.array([
[0, 0, 1, 1],
[0, 1, 1, 0]
])
plot_transformation(Square, F_shear @ Square, "$Square$", "$F_{shear}
Square$",
axis=[0, 2.6, 0, 1.8])
plt.show()
F_squeeze = np.array([
[1.4, 0],
[0, 1/1.4]
])
plot_transformation(P, F_squeeze @ P, "$P$", "$F_{squeeze} P$",
axis=[0, 7, 0, 5])
plt.show()
The effect on the unit square is:
Matrix inverse
Now that we understand that a matrix can represent any linear transformation, a natural
question is: can we find a transformation matrix that reverses the effect of a given
transformation matrix F ? The answer is yes… sometimes! When it exists, such a matrix is called
the inverse of F , and it is noted F −1.
For example, the rotation, the shear mapping and the squeeze mapping above all have inverse
transformations. Let's demonstrate this on the shear mapping:
F_inv_shear = np.array([
[1, -1.5],
[0, 1]
])
P_sheared = F_shear @ P
P_unsheared = F_inv_shear @ P_sheared
plot_transformation(P_sheared, P_unsheared, "$P_{sheared}$",
"$P_{unsheared}$",
axis=[0, 10, 0, 7])
plt.plot(P[0], P[1], "b--")
plt.show()
We applied a shear mapping on P , just like we did before, but then we applied a second
transformation to the result, and lo and behold this had the effect of coming back to the original
P (I've plotted the original P's outline to double-check). The second transformation is the
inverse of the first one.
−1
We defined the inverse matrix F s h e a r manually this time, but NumPy provides an inv function to
compute a matrix's inverse, so we could have written instead:
F_inv_shear = LA.inv(F_shear)
F_inv_shear
array([[ 1. , -1.5],
[ 0. , 1. ]])
Only square matrices can be inversed. This makes sense when you think about it: if you have a
transformation that reduces the number of dimensions, then some information is lost and there
is no way that you can get it back. For example say you use a 2 ×3 matrix to project a 3D object
onto a plane. The result may look like this:
Looking at this image, it is impossible to tell whether this is the projection of a cube or the
projection of a narrow rectangular object. Some information has been lost in the projection.
Even square transformation matrices can lose information. For example, consider this
transformation matrix:
F_project = np.array([
[1, 0],
[0, 0]
])
plot_transformation(P, F_project @ P, "$P$", r"$F_{project} \cdot P$",
axis=[0, 6, -1, 4])
plt.show()
This transformation matrix performs a projection onto the horizontal axis. Our polygon gets
entirely flattened out so some information is entirely lost, and it is impossible to go back to the
original polygon using a linear transformation. In other words, F p r o j e c t has no inverse. Such a
square matrix that cannot be inversed is called a singular matrix (aka degenerate matrix). If we
ask NumPy to calculate its inverse, it raises an exception:
try:
LA.inv(F_project)
except LA.LinAlgError as e:
print("LinAlgError:", e)
Here is another example of a singular matrix. This one performs a projection onto the axis at a
30° angle above the horizontal axis:
LA.inv(F_project_30)
As you might expect, the dot product of a matrix by its inverse results in the identity matrix:
−1 −1
M ⋅ M =M ⋅ M =I
This makes sense since doing a linear transformation followed by the inverse transformation
results in no change at all.
F_shear @ LA.inv(F_shear)
array([[1., 0.],
[0., 1.]])
Another way to express this is that the inverse of the inverse of a matrix M is M itself:
−1
( ( M ) − 1) =M
LA.inv(LA.inv(F_shear))
array([[1. , 1.5],
[0. , 1. ]])
1
Also, the inverse of scaling by a factor of λ is of course scaling by a factor of :
λ
$ (\lambda \times M)^{-1} = \frac{1}{\lambda} \times M^{-1}$
Once you understand the geometric interpretation of matrices as linear transformations, most
of these properties seem fairly intuitive.
A matrix that is its own inverse is called an involution. The simplest examples are reflection
matrices, or a rotation by 180°, but there are also more complex involutions, for example
imagine a transformation that squeezes horizontally, then reflects over the vertical axis and
finally rotates by 90° clockwise. Pick up a napkin and try doing that twice: you will end up in the
original position. Here is the corresponding involutory matrix:
F_involution = np.array([
[0, -2],
[-1/2, 0]
])
plot_transformation(P, F_involution @ P, "$P$", r"$F_{involution} \
cdot P$",
axis=[-8, 5, -4, 4])
plt.show()
Finally, a square matrix H whose inverse is its own transpose is an orthogonal matrix:
−1 T
H =H
Therefore:
T T
H ⋅ H =H ⋅ H=I
It corresponds to a transformation that preserves distances, such as rotations and reflections,
and combinations of these, but not rescaling, shearing or squeezing. Let's check that F r e f l e ct is
indeed orthogonal:
F_reflect @ F_reflect.T
array([[1, 0],
[0, 1]])
Determinant
The determinant of a square matrix M , noted det ( M ) or det M or |M ) is a value that can be
calculated from its elements ( M i , j ) using various equivalent methods. One of the simplest
methods is this recursive approach:
[ )
1 2 3
M= 4 5 6
7 8 0
|[ )) |[ )) |[ ))
|M )=1 × 5 6 −2 × 4 6 +3 × 4 5
8 0 7 0 7 8
Now we need to compute the determinant of each of these 2 ×2 matrices (these determinants
are called minors):
|[ 5 6
8 0 ))
=5 × 0− 6 ×8=− 48
|[ 4 6
7 0 ))
=4 ×0 −6 × 7=−42
|[ 4 5
7 8 ))
=4 ×8 −5 ×7=− 3
|M )=1 × (− 48 ) −2 × (− 42 ) +3 × (− 3 )=27
To get the determinant of a matrix, you can call NumPy's det function in the numpy.linalg
module:
M = np.array([
[1, 2, 3],
[4, 5, 6],
[7, 8, 0]
])
LA.det(M)
27.0
One of the main uses of the determinant is to determine whether a square matrix can be
inversed or not: if the determinant is equal to 0, then the matrix cannot be inversed (it is a
singular matrix), and if the determinant is not 0, then it can be inversed.
For example, let's compute the determinant for the F p r o j e c t, F p r o j e c t and F s h e a r matrices that
30
we defined earlier:
LA.det(F_project)
0.0
LA.det(F_project_30)
2.0816681711721642e-17
This determinant is suspiciously close to 0: it really should be 0, but it's not due to tiny floating
point errors. The matrix is actually singular.
LA.det(F_shear)
1.0
Perfect! This matrix can be inversed as we saw earlier. Wow, math really works!
The determinant can also be used to measure how much a linear transformation affects surface
areas: for example, the projection matrices F p r o j e c t and F p r o j e c t completely flatten the polygon
30
P, until its area is zero. This is why the determinant of these matrices is 0. The shear mapping
modified the shape of the polygon, but it did not affect its surface area, which is why the
determinant is 1. You can try computing the determinant of a rotation matrix, and you should
also find 1. What about a scaling matrix? Let's see:
F_scale = np.array([
[0.5, 0],
[0, 0.5]
])
plot_transformation(P, F_scale @ P, "$P$", r"$F_{scale} \cdot P$",
axis=[0, 6, -1, 4])
plt.show()
We rescaled the polygon by a factor of 1/2 on both vertical and horizontal axes so the surface
area of the resulting polygon is 1/4❑t h of the original polygon. Let's compute the determinant
and check that:
LA.det(F_scale)
0.25
Correct!
The determinant can actually be negative, when the transformation results in a "flipped over"
version of the original polygon (e.g. a left-hand glove becomes a right-hand glove). For example,
the determinant of the F_reflect matrix is -1 because the surface area is preserved but the
polygon gets flipped over:
LA.det(F_reflect)
-1.0
Note that the order of the transformations is the reverse of the dot product order.
If we are going to perform this composition of linear transformations more than once, we might
as well save the composition matrix like this:
From now on we can perform both transformations in just one dot product, which can lead to a
very significant performance boost.
What if you want to perform the inverse of this double transformation? Well, if you squeezed
and then you sheared, and you want to undo what you have done, it should be obvious that you
should unshear first and then unsqueeze. In more mathematical terms, given two invertible (aka
nonsingular) matrices Q and R :
( Q ⋅ R )−1=R −1 ⋅Q−1
And in NumPy:
S_diag
array([2. , 0.5])
Note that this is just a 1D array containing the diagonal values of Σ. To get the actual matrix Σ,
we can use NumPy's diag function:
S = np.diag(S_diag)
S
array([[2. , 0. ],
[0. , 0.5]])
U @ np.diag(S_diag) @ V_T
F_shear
array([[1. , 1.5],
[0. , 1. ]])
It worked like a charm. Let's apply these transformations one by one (in reverse order) on the
unit square to understand what's going on. First, let's apply the first rotation V T :
And we can see that the result is indeed a shear mapping of the original unit square.
For example, any horizontal vector remains horizontal after applying the shear mapping (as you
can see on the image above), so it is an eigenvector of M . A vertical vector ends up tilted to the
right, so vertical vectors are NOT eigenvectors of M .
If we look at the squeeze mapping, we find that any horizontal or vertical vector keeps its
direction (although its length changes), so all horizontal and vertical vectors are eigenvectors of
F s q u e e z e.
However, rotation matrices have no eigenvectors at all (except if the rotation angle is 0° or 180°,
in which case all non-zero vectors are eigenvectors).
NumPy's eig function returns the list of unit eigenvectors and their corresponding eigenvalues
for any square matrix. Let's look at the eigenvectors and eigenvalues of the squeeze mapping
matrix F s q u e e z e:
array([1.4 , 0.71428571])
array([[1., 0.],
[0., 1.]])
Indeed, the horizontal vectors are stretched by a factor of 1.4, and the vertical vectors are shrunk
by a factor of 1/1.4=0.714…, so far so good. Let's look at the shear mapping matrix F s h e a r:
array([1., 1.])
Wait, what!? We expected just one unit eigenvector, not two. The second vector is almost equal
to (−01), which is on the same line as the first vector (10). This is due to floating point errors. We
can safely ignore vectors that are (almost) collinear (i.e. on the same line).
Trace
The trace of a square matrix M , noted t r ( M ) is the sum of the values on its main diagonal. For
example:
D = np.array([
[100, 200, 300],
[ 10, 20, 30],
[ 1, 2, 3],
])
D.trace()
123
The trace does not have a simple geometric interpretation (in general), but it has a number of
properties that make it useful in many areas:
• t r ( A+ B ) =t r ( A ) +t r ( B )
• t r ( A ⋅ B )=t r ( B ⋅ A )
• t r ( A ⋅ B ⋅⋯⋅Y ⋅ Z )=t r ( Z ⋅ A ⋅ B ⋅⋯⋅ Y )
• t r ( A T ⋅B )=t r ( A ⋅ BT )=t r ( BT ⋅ A )=t r ( B ⋅ AT )=∑ X i , j ×Y i, j
i, j
• …
It does, however, have a useful geometric interpretation in the case of projection matrices (such
as F p r o j e c t that we discussed earlier): it corresponds to the number of dimensions after
projection. For example:
F_project.trace()
What's next?
This concludes this introduction to Linear Algebra. Although these basics cover most of what
you will need to know for Machine Learning, if you wish to go deeper into this topic there are
many options available: Linear Algebra books, Khan Academy lessons, or just Wikipedia pages.