How Non-Singular Matrices Act on Space

This notebook builds geometric intuition for matrix transformations, covering:

  • The active vs. passive interpretation
  • Columns of a matrix as images of basis vectors
  • What non-singularity means geometrically
  • The SVD as a decomposition of the transformation into interpretable steps
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.patches import FancyArrowPatch
from matplotlib.gridspec import GridSpec

# ── shared helpers ──────────────────────────────────────────────────────────

def draw_grid(ax, A=None, color='lightgray', alpha=0.5, lw=0.8, extent=3):
    """Draw a transformed (or standard) grid."""
    lines = np.linspace(-extent, extent, 2 * extent + 1)
    for v in lines:
        pts_h = np.array([[-extent, v], [extent, v]]).T   # horizontal line
        pts_v = np.array([[v, -extent], [v,  extent]]).T  # vertical line
        if A is not None:
            pts_h = A @ pts_h
            pts_v = A @ pts_v
        ax.plot(*pts_h, color=color, alpha=alpha, lw=lw, zorder=0)
        ax.plot(*pts_v, color=color, alpha=alpha, lw=lw, zorder=0)

def draw_arrow(ax, vec, origin=None, color='black', lw=2, label=None, label_offset=None):
    if origin is None:
        origin = np.zeros(2)
    ax.annotate('', xy=origin + vec, xytext=origin,
                arrowprops=dict(arrowstyle='->', color=color, lw=lw))
    if label:
        pos = origin + vec + (label_offset if label_offset is not None else np.array([0.1, 0.1]))
        ax.text(*pos, label, color=color, fontsize=12)

def style_ax(ax, title='', xlim=(-3.5, 3.5), ylim=(-3.5, 3.5)):
    ax.set_xlim(*xlim); ax.set_ylim(*ylim)
    ax.set_aspect('equal')
    ax.axhline(0, color='gray', lw=0.6); ax.axvline(0, color='gray', lw=0.6)
    ax.set_title(title, fontsize=11, pad=8)
    ax.set_xlabel('$x_1$'); ax.set_ylabel('$x_2$')

1. Active vs. Passive Interpretation

Given a matrix $A$ and a vector $v$, the product $Av$ has two valid readings:

Active — $A$ moves $v$ to a new location in the same coordinate system.
Passive — $A$ redefines the rulers; the vector stays put but its coordinates change.

Both produce the same numbers. The distinction is conceptual: are the vectors moving, or is the coordinate system?

Below we show both views of the same matrix $A = \begin{bmatrix} 2 & 0.5 \ 0.5 & 1.5 \end{bmatrix}$ acting on $v = [1, 1]^\top$.

A = np.array([[2.0, 0.5],
              [0.5, 1.5]])
v = np.array([1.0, 1.0])
Av = A @ v

fig, axes = plt.subplots(1, 2, figsize=(13, 5))

# ── Active view ─────────────────────────────────────────────────────────────
ax = axes[0]
draw_grid(ax, color='lightgray')
draw_arrow(ax, v,  color='steelblue', lw=2.5, label='$v$',  label_offset=np.array([ 0.05,  0.15]))
draw_arrow(ax, Av, color='crimson',   lw=2.5, label='$Av$', label_offset=np.array([ 0.1, -0.3]))
style_ax(ax, title='Active view — $A$ moves $v$ to $Av$\n(coordinate system fixed)')

# ── Passive view ─────────────────────────────────────────────────────────────
ax = axes[1]
draw_grid(ax, color='lightgray')       # original grid (faint)
draw_grid(ax, A=A, color='#90c4e4', alpha=0.7, lw=1.2)  # transformed grid
draw_arrow(ax, v, color='steelblue', lw=2.5, label='$v$', label_offset=np.array([0.05, 0.15]))
style_ax(ax, title='Passive view — $A$ reshapes the coordinate grid\n(vector $v$ stays fixed)')
handles = [
    mpatches.Patch(facecolor='lightgray', label='Original grid'),
    mpatches.Patch(facecolor='#90c4e4',   label='Transformed grid'),
]
axes[1].legend(handles=handles, loc='lower right', fontsize=9)

fig.suptitle('Two interpretations of the same matrix $A$', fontsize=13, y=1.01)
plt.tight_layout()
plt.show()

2. Columns of $A$ as Images of Basis Vectors

The most useful geometric fact:

The $j$-th column of $A$ is exactly where the $j$-th standard basis vector $e_j$ lands.

This is because $A e_j$ selects the $j$-th column. So the matrix is completely described by where it sends the axes:

$$A = \begin{bmatrix} | & | \ Ae_1 & Ae_2 \ | & | \end{bmatrix}$$

Everything else follows by linearity — any vector $v = v_1 e_1 + v_2 e_2$ maps to $Av = v_1(Ae_1) + v_2(Ae_2)$.

e1 = np.array([1.0, 0.0])
e2 = np.array([0.0, 1.0])
Ae1, Ae2 = A @ e1, A @ e2

print(f"A =\n{A}")
print(f"\nAe1 = {Ae1}  ← first column of A")
print(f"Ae2 = {Ae2}  ← second column of A")

# Verify linearity: A @ v = v[0]*Ae1 + v[1]*Ae2
v_reconstructed = v[0] * Ae1 + v[1] * Ae2
print(f"\nv = {v}")
print(f"Av via matrix multiply : {A @ v}")
print(f"Av via linear combo    : {v_reconstructed}  ✓")
A =
[[2.  0.5]
 [0.5 1.5]]

Ae1 = [2.  0.5]  ← first column of A
Ae2 = [0.5 1.5]  ← second column of A

v = [1. 1.]
Av via matrix multiply : [2.5 2. ]
Av via linear combo    : [2.5 2. ]  ✓
fig, axes = plt.subplots(1, 2, figsize=(13, 5))

for ax, (title, draw_A) in zip(axes, [
    ('Before: standard basis', False),
    ('After: columns of $A$ are new basis', True)
]):
    draw_grid(ax, A=(A if draw_A else None), color='lightgray')
    if draw_A:
        draw_arrow(ax, Ae1, color='crimson',   lw=2.5, label='$Ae_1$ (col 1)', label_offset=np.array([0.1, -0.35]))
        draw_arrow(ax, Ae2, color='darkorange', lw=2.5, label='$Ae_2$ (col 2)', label_offset=np.array([0.1,  0.1]))
        # show transformed v
        draw_arrow(ax, A @ v, color='purple', lw=2, label='$Av$', label_offset=np.array([0.1, 0.1]))
    else:
        draw_arrow(ax, e1, color='crimson',    lw=2.5, label='$e_1$', label_offset=np.array([ 0.05, -0.25]))
        draw_arrow(ax, e2, color='darkorange', lw=2.5, label='$e_2$', label_offset=np.array([-0.35,  0.1]))
        draw_arrow(ax, v,  color='purple',     lw=2,   label='$v$',   label_offset=np.array([ 0.1,   0.1]))
    style_ax(ax, title=title, xlim=(-3.5, 3.5), ylim=(-2, 4))

fig.suptitle('Columns of $A$ are the images of $e_1$ and $e_2$', fontsize=13, y=1.01)
plt.tight_layout()
plt.show()

3. Non-Singular vs. Singular: Geometric Meaning

A matrix is non-singular (invertible) when its columns are linearly independent — they still span the full space after transformation. A singular matrix collapses at least one dimension.

The determinant measures the signed area (2D) or volume (nD) scaling factor:

$$\det(A) \neq 0 \iff \text{non-singular} \iff \text{no dimension is collapsed}$$

Below we compare three matrices:

  • $A_1$ — non-singular, stretches and rotates
  • $A_2$ — singular, collapses 2D onto a line
  • $A_3$ — non-singular, reflection (det < 0, orientation flipped)
# Unit square corners (and closing point)
square = np.array([[0,1,1,0,0],
                   [0,0,1,1,0]], dtype=float)

A1 = np.array([[2.0,  0.5],   # non-singular stretch
               [0.5,  1.5]])
A2 = np.array([[1.0,  2.0],   # singular: col2 = 2*col1
               [0.5,  1.0]])
A3 = np.array([[-1.0, 0.0],   # reflection across y-axis
               [ 0.0, 1.0]])

matrices = [(A1, 'Non-singular\n$A_1$, det={:.2f}'.format(np.linalg.det(A1))),
            (A2, 'Singular\n$A_2$, det={:.2f}'.format(np.linalg.det(A2))),
            (A3, 'Non-singular (reflection)\n$A_3$, det={:.2f}'.format(np.linalg.det(A3)))]

fig, axes = plt.subplots(2, 3, figsize=(14, 9))

for col, (M, label) in enumerate(matrices):
    for row, (title_sfx, use_M) in enumerate([('Before', False), ('After', True)]):
        ax = axes[row][col]
        draw_grid(ax, A=(M if use_M else None))
        sq = M @ square if use_M else square
        ax.fill(sq[0], sq[1], alpha=0.25, color='steelblue')
        ax.plot(sq[0], sq[1], color='steelblue', lw=2)
        t = label if use_M else 'Standard basis'
        style_ax(ax, title=t, xlim=(-4,4), ylim=(-3,4))
        if use_M:
            c1, c2 = M[:, 0], M[:, 1]
            draw_arrow(ax, c1, color='crimson',    lw=2, label='col 1', label_offset=np.array([ 0.1, -0.3]))
            draw_arrow(ax, c2, color='darkorange', lw=2, label='col 2', label_offset=np.array([ 0.1,  0.1]))

axes[0][0].set_ylabel('Before', fontsize=12)
axes[1][0].set_ylabel('After $A$', fontsize=12)
fig.suptitle('Effect of non-singular vs. singular matrices on the unit square', fontsize=13)
plt.tight_layout()
plt.show()

Observations:

  • $A_1$ (non-singular): the square becomes a parallelogram — area changes but it remains 2D
  • $A_2$ (singular): the square collapses to a line segment — all area is destroyed, the transformation is irreversible
  • $A_3$ (reflection): the square flips — the negative determinant signals the orientation reversal

4. Determinant as a Volume Scaling Factor

The determinant is not just a number — it is the signed area scaling factor of the transformation.

$$\text{Area after} = |\det(A)| \times \text{Area before}$$

The sign encodes orientation: positive means the transformation preserves orientation (no reflection), negative means it reverses it.

unit_area = 1.0  # area of unit square

for M, name in [(A1,'A1 (non-singular)'), (A2,'A2 (singular)'), (A3,'A3 (reflection)')]:
    d = np.linalg.det(M)
    print(f"{name}: det = {d:+.4f},  area after = {abs(d):.4f},  orientation {'preserved' if d > 0 else 'reversed' if d < 0 else 'destroyed'}")
A1 (non-singular): det = +2.7500,  area after = 2.7500,  orientation preserved
A2 (singular): det = +0.0000,  area after = 0.0000,  orientation destroyed
A3 (reflection): det = -1.0000,  area after = 1.0000,  orientation reversed

5. SVD Decomposes the Transformation into Three Steps

The SVD $A = U \Sigma V^\top$ breaks every matrix transformation into three interpretable steps:

$$v \;\xrightarrow{V^\top}\; \text{rotate/reflect} \;\xrightarrow{\Sigma}\; \text{scale along axes} \;\xrightarrow{U}\; \text{rotate/reflect}$$

  • $V^\top$ — rotates the input to align with the matrix’s natural input directions (right singular vectors)
  • $\Sigma$ — stretches or compresses each axis independently by the singular values $\sigma_i$
  • $U$ — rotates the result into the output orientation (left singular vectors)

Non-singular means all $\sigma_i > 0$ — every axis is scaled by a nonzero amount, so no dimension is lost.

U, s, Vt = np.linalg.svd(A1)
Sigma = np.diag(s)

print(f"A1 = U Σ Vᵀ")
print(f"\nU  =\n{np.round(U, 3)}   (rotation/reflection)")
print(f"\nΣ  = diag{np.round(s, 3)}  (scaling: σ1={s[0]:.3f}, σ2={s[1]:.3f})")
print(f"\nVᵀ =\n{np.round(Vt, 3)}   (rotation/reflection)")
print(f"\nReconstructed: U @ Σ @ Vᵀ =\n{np.round(U @ Sigma @ Vt, 6)}")
print(f"Original A1:\n{A1}  ✓")
A1 = U Σ Vᵀ

U  =
[[-0.851 -0.526]
 [-0.526  0.851]]   (rotation/reflection)

Σ  = diag[2.309 1.191]  (scaling: σ1=2.309, σ2=1.191)

Vᵀ =
[[-0.851 -0.526]
 [-0.526  0.851]]   (rotation/reflection)

Reconstructed: U @ Σ @ Vᵀ =
[[2.  0.5]
 [0.5 1.5]]
Original A1:
[[2.  0.5]
 [0.5 1.5]]  ✓
# Visualise the four stages: v → Vᵀv → ΣVᵀv → UΣVᵀv
fig, axes = plt.subplots(1, 4, figsize=(18, 5))

# We'll track the unit circle and the standard basis arrows through each stage
theta = np.linspace(0, 2 * np.pi, 300)
circle = np.row_stack([np.cos(theta), np.sin(theta)])

stages = [
    (np.eye(2),      'Stage 0: Input space\n(unit circle + basis)'),
    (Vt,             'Stage 1: Apply $V^\\top$\n(rotate to align with singular vectors)'),
    (Sigma @ Vt,     'Stage 2: Apply $\\Sigma$\n(scale: stretch/compress)'),
    (U @ Sigma @ Vt, 'Stage 3: Apply $U$\n(rotate to output orientation = $A_1$)'),
]

for ax, (M, title) in zip(axes, stages):
    draw_grid(ax, A=M if not np.allclose(M, np.eye(2)) else None)
    c = M @ circle
    ax.plot(c[0], c[1], color='steelblue', lw=2)
    ax.fill(c[0], c[1], color='steelblue', alpha=0.15)
    draw_arrow(ax, M[:, 0], color='crimson',    lw=2)
    draw_arrow(ax, M[:, 1], color='darkorange', lw=2)
    style_ax(ax, title=title, xlim=(-3.5, 3.5), ylim=(-3.5, 3.5))

fig.suptitle('SVD decomposition: $A_1 = U\\Sigma V^\\top$ — three geometric stages', fontsize=13)
plt.tight_layout()
plt.show()

6. What Non-Singularity Guarantees

Bringing it all together:

print("=== Non-singular matrix A1 ===")
print(f"  Singular values      : {np.round(s, 4)}  (all > 0 ✓)")
print(f"  Determinant          : {np.linalg.det(A1):.4f}  (≠ 0 ✓)")
print(f"  Rank                 : {np.linalg.matrix_rank(A1)}  (= n ✓)")
print(f"  Condition number κ   : {s[0]/s[-1]:.2f}  (finite ✓)")
print(f"  Invertible           : Yes")

print()
print("=== Singular matrix A2 ===")
U2, s2, Vt2 = np.linalg.svd(A2)
print(f"  Singular values      : {np.round(s2, 6)}  (one = 0 ✗)")
print(f"  Determinant          : {np.linalg.det(A2):.6f}  (≈ 0 ✗)")
print(f"  Rank                 : {np.linalg.matrix_rank(A2)}  (< n ✗)")
print(f"  Condition number κ   : ∞")
print(f"  Invertible           : No — collapses 2D space onto a 1D line")
=== Non-singular matrix A1 ===
  Singular values      : [2.309 1.191]  (all > 0 ✓)
  Determinant          : 2.7500  (≠ 0 ✓)
  Rank                 : 2  (= n ✓)
  Condition number κ   : 1.94  (finite ✓)
  Invertible           : Yes

=== Singular matrix A2 ===
  Singular values      : [2.5 0. ]  (one = 0 ✗)
  Determinant          : 0.000000  (≈ 0 ✗)
  Rank                 : 1  (< n ✗)
  Condition number κ   : ∞
  Invertible           : No — collapses 2D space onto a 1D line

Summary

ConceptGeometric meaning
Active interpretation$A$ moves vectors; coordinate system is fixed
Passive interpretation$A$ reshapes the grid; vectors are fixed
Columns of $A$Where the standard basis vectors $e_j$ land
Non-singularColumns independent → no dimension collapsed → full rank → invertible
$\det(A) \neq 0$Area/volume is scaled but not destroyed
$\det(A) < 0$Orientation is reversed (reflection component present)
$\det(A) = 0$At least one dimension is collapsed — transformation irreversible
SVD $A = U\Sigma V^\top$Rotate → scale along axes → rotate
All $\sigma_i > 0$Equivalent condition for non-singularity via SVD

The active and passive views are two lenses on the same object. Choosing between them is a matter of which makes the problem at hand easier to reason about — not a question of which is correct.

Leave a Reply

Your email address will not be published. Required fields are marked *