Chapter 2: Linear combinations, span, and basis vectors

1. What is a Transformation?

Function vs Transformation

  • Function: Input → Output (abstract)

  • Transformation: Visualize inputs moving to outputs

The Movement Metaphor

Instead of thinking “this vector maps to that vector,” think:

“Space is moving and morphing, taking every vector along for the ride”

We’ll visualize transformations by watching:

  • Grid lines move

  • Vectors move

  • The entire 2D plane morphs

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import FancyArrowPatch, Circle
from matplotlib.animation import FuncAnimation
from IPython.display import HTML
import seaborn as sns

sns.set_style('whitegrid')
plt.rcParams['figure.figsize'] = (10, 8)
np.set_printoptions(precision=3, suppress=True)
def draw_grid(ax, xlim=(-5, 5), ylim=(-5, 5), spacing=1, color='lightgray', alpha=0.5):
    """Draw a coordinate grid"""
    for x in np.arange(xlim[0], xlim[1]+1, spacing):
        ax.plot([x, x], ylim, color=color, alpha=alpha, linewidth=0.5)
    for y in np.arange(ylim[0], ylim[1]+1, spacing):
        ax.plot(xlim, [y, y], color=color, alpha=alpha, linewidth=0.5)

def draw_basis_vectors(ax, i_hat, j_hat, color_i='green', color_j='red'):
    """Draw basis vectors"""
    arrow_props = dict(arrowstyle='->', mutation_scale=20, linewidth=3)
    
    # i-hat (typically horizontal)
    arrow_i = FancyArrowPatch((0, 0), tuple(i_hat), 
                             color=color_i, **arrow_props)
    ax.add_patch(arrow_i)
    ax.text(i_hat[0]*1.1, i_hat[1]*1.1, 'î', fontsize=16, 
           color=color_i, fontweight='bold')
    
    # j-hat (typically vertical)
    arrow_j = FancyArrowPatch((0, 0), tuple(j_hat), 
                             color=color_j, **arrow_props)
    ax.add_patch(arrow_j)
    ax.text(j_hat[0]*1.1, j_hat[1]*1.1, 'ĵ', fontsize=16, 
           color=color_j, fontweight='bold')

def setup_ax(ax, xlim=(-5, 5), ylim=(-5, 5), title=''):
    """Setup axis properties"""
    ax.set_xlim(xlim)
    ax.set_ylim(ylim)
    ax.set_aspect('equal')
    ax.axhline(y=0, color='k', linewidth=0.8)
    ax.axvline(x=0, color='k', linewidth=0.8)
    ax.grid(True, alpha=0.3)
    ax.set_xlabel('x', fontsize=12)
    ax.set_ylabel('y', fontsize=12)
    if title:
        ax.set_title(title, fontsize=14, fontweight='bold')

# Example: Visualizing a transformation as movement
fig, axes = plt.subplots(1, 2, figsize=(16, 7))

# Before transformation
setup_ax(axes[0], title='Before: Original Space')
draw_grid(axes[0])
draw_basis_vectors(axes[0], np.array([1, 0]), np.array([0, 1]))

# Sample vectors
v = np.array([2, 1])
arrow_v = FancyArrowPatch((0, 0), tuple(v), 
                         color='blue', arrowstyle='->', 
                         mutation_scale=20, linewidth=2)
axes[0].add_patch(arrow_v)
axes[0].text(v[0]+0.2, v[1]+0.2, 'v', fontsize=14, color='blue', fontweight='bold')

# After transformation (rotation by 45°)
theta = np.pi/4
rotation_matrix = np.array([
    [np.cos(theta), -np.sin(theta)],
    [np.sin(theta), np.cos(theta)]
])

setup_ax(axes[1], title='After: Rotated 45°')

# Transform and draw grid
grid_x = np.arange(-5, 6, 1)
grid_y = np.arange(-5, 6, 1)

# Vertical grid lines (transformed)
for x in grid_x:
    points = np.array([[x, y] for y in grid_y])
    transformed = points @ rotation_matrix.T
    axes[1].plot(transformed[:, 0], transformed[:, 1], 
                'lightgray', alpha=0.5, linewidth=0.5)

# Horizontal grid lines (transformed)
for y in grid_y:
    points = np.array([[x, y] for x in grid_x])
    transformed = points @ rotation_matrix.T
    axes[1].plot(transformed[:, 0], transformed[:, 1], 
                'lightgray', alpha=0.5, linewidth=0.5)

# Transformed basis vectors
i_transformed = rotation_matrix @ np.array([1, 0])
j_transformed = rotation_matrix @ np.array([0, 1])
draw_basis_vectors(axes[1], i_transformed, j_transformed)

# Transformed vector
v_transformed = rotation_matrix @ v
arrow_v_t = FancyArrowPatch((0, 0), tuple(v_transformed), 
                           color='blue', arrowstyle='->', 
                           mutation_scale=20, linewidth=2)
axes[1].add_patch(arrow_v_t)
axes[1].text(v_transformed[0]+0.2, v_transformed[1]+0.2, 'v (moved)', 
            fontsize=14, color='blue', fontweight='bold')

plt.tight_layout()
plt.show()

print("Key Insight: A transformation moves EVERY point in space.")
print("Watch the grid lines - they show how space itself is morphing!")

2. What Makes a Transformation “Linear”?

A transformation is linear if it satisfies TWO properties:

Property 1: Lines remain lines

  • No curving allowed!

  • Grid lines must stay parallel and evenly spaced

Property 2: Origin stays fixed

  • The point (0, 0) doesn’t move

Mathematical Definition

A transformation \(L\) is linear if:

  1. \(L(\vec{v} + \vec{w}) = L(\vec{v}) + L(\vec{w})\) (additivity)

  2. \(L(c\vec{v}) = cL(\vec{v})\) (homogeneity)

Consequence

If you know where \(\hat{i}\) and \(\hat{j}\) land, you know where every vector lands!

# Compare linear vs non-linear transformations
fig, axes = plt.subplots(2, 3, figsize=(18, 12))

# Create dense grid of points
x = np.linspace(-3, 3, 20)
y = np.linspace(-3, 3, 20)
X, Y = np.meshgrid(x, y)
points = np.stack([X.flatten(), Y.flatten()], axis=1)

# LINEAR TRANSFORMATIONS (TOP ROW)
linear_transforms = [
    ('Rotation', np.array([[0, -1], [1, 0]])),
    ('Shear', np.array([[1, 1], [0, 1]])),
    ('Scaling', np.array([[2, 0], [0, 0.5]]))
]

for idx, (name, matrix) in enumerate(linear_transforms):
    ax = axes[0, idx]
    setup_ax(ax, title=f'LINEAR: {name}')
    
    # Original grid (light)
    draw_grid(ax, color='lightblue', alpha=0.3)
    
    # Transformed grid
    transformed = points @ matrix.T
    X_t = transformed[:, 0].reshape(X.shape)
    Y_t = transformed[:, 1].reshape(Y.shape)
    
    # Draw transformed grid lines
    for i in range(len(x)):
        ax.plot(X_t[i, :], Y_t[i, :], 'gray', alpha=0.6, linewidth=1)
        ax.plot(X_t[:, i], Y_t[:, i], 'gray', alpha=0.6, linewidth=1)
    
    # Mark origin
    ax.plot(0, 0, 'ko', markersize=8)
    
    # Transformed basis
    i_new = matrix @ np.array([1, 0])
    j_new = matrix @ np.array([0, 1])
    draw_basis_vectors(ax, i_new, j_new)

# NON-LINEAR TRANSFORMATIONS (BOTTOM ROW)
def quadratic_transform(p):
    return np.array([p[0]**2 - p[1]**2, 2*p[0]*p[1]]) * 0.2

def translation(p):
    return p + np.array([1, 1])

def circular(p):
    r = np.sqrt(p[0]**2 + p[1]**2)
    if r < 0.1:
        return p
    theta = np.arctan2(p[1], p[0])
    return np.array([r * np.cos(theta + 0.3), r * np.sin(theta + 0.3)])

nonlinear_transforms = [
    ('Curved Lines', quadratic_transform),
    ('Moved Origin', translation),
    ('Circular Warp', circular)
]

for idx, (name, func) in enumerate(nonlinear_transforms):
    ax = axes[1, idx]
    setup_ax(ax, title=f'NON-LINEAR: {name}')
    
    # Original grid (light)
    draw_grid(ax, color='lightblue', alpha=0.3)
    
    # Apply non-linear transformation
    transformed = np.array([func(p) for p in points])
    X_t = transformed[:, 0].reshape(X.shape)
    Y_t = transformed[:, 1].reshape(Y.shape)
    
    # Draw transformed grid lines
    for i in range(len(x)):
        ax.plot(X_t[i, :], Y_t[i, :], 'gray', alpha=0.6, linewidth=1)
        ax.plot(X_t[:, i], Y_t[:, i], 'gray', alpha=0.6, linewidth=1)
    
    # Mark where origin moved to
    origin_transformed = func(np.array([0, 0]))
    ax.plot(origin_transformed[0], origin_transformed[1], 
           'ro', markersize=8, label='Origin moved here')
    ax.plot(0, 0, 'ko', markersize=5, alpha=0.3, label='Original origin')
    if idx == 1:
        ax.legend()

plt.tight_layout()
plt.show()

print("LINEAR transformations:")
print("  ✓ Grid lines stay STRAIGHT and PARALLEL")
print("  ✓ Origin stays PUT")
print("  ✓ Can be represented by a MATRIX")
print("\nNON-LINEAR transformations:")
print("  ✗ Lines can CURVE")
print("  ✗ Origin can MOVE")
print("  ✗ Cannot be represented by a matrix")

3. Tracking Basis Vectors

The Key Insight

For linear transformations, you only need to know where \(\hat{i}\) and \(\hat{j}\) go!

Why?

Because any vector \(\vec{v} = \begin{bmatrix} x \\ y \end{bmatrix}\) is really: $\(\vec{v} = x\hat{i} + y\hat{j}\)$

After transformation: $\(L(\vec{v}) = L(x\hat{i} + y\hat{j}) = xL(\hat{i}) + yL(\hat{j})\)$

Example

If \(\hat{i}\) lands at \(\begin{bmatrix} 1 \\ 2 \end{bmatrix}\) and \(\hat{j}\) lands at \(\begin{bmatrix} 3 \\ 0 \end{bmatrix}\),

then \(\begin{bmatrix} 5 \\ 7 \end{bmatrix}\) lands at: $\(5\begin{bmatrix} 1 \\ 2 \end{bmatrix} + 7\begin{bmatrix} 3 \\ 0 \end{bmatrix} = \begin{bmatrix} 26 \\ 10 \end{bmatrix}\)$

# Demonstrate: tracking basis vectors determines everything
fig, axes = plt.subplots(1, 3, figsize=(18, 6))

# Original space
setup_ax(axes[0], title='Original Space')
draw_grid(axes[0])
draw_basis_vectors(axes[0], np.array([1, 0]), np.array([0, 1]))

v = np.array([3, 2])
arrow_v = FancyArrowPatch((0, 0), tuple(v), 
                         color='purple', arrowstyle='->', 
                         mutation_scale=20, linewidth=3)
axes[0].add_patch(arrow_v)
axes[0].text(v[0]+0.3, v[1]+0.3, 'v = 3î + 2ĵ', 
            fontsize=14, color='purple', fontweight='bold')

# Show decomposition
axes[0].arrow(0, 0, 3, 0, head_width=0.15, head_length=0.15, 
             fc='green', ec='green', alpha=0.5, width=0.05)
axes[0].arrow(3, 0, 0, 2, head_width=0.15, head_length=0.15, 
             fc='red', ec='red', alpha=0.5, width=0.05)
axes[0].text(1.5, -0.5, '3î', fontsize=12, color='green')
axes[0].text(3.3, 1, '2ĵ', fontsize=12, color='red')

# Define a transformation
i_new = np.array([1, 2])
j_new = np.array([3, 0])
matrix = np.column_stack([i_new, j_new])

# Show where basis vectors go
setup_ax(axes[1], xlim=(-1, 5), ylim=(-1, 4), 
        title='Where do î and ĵ land?')
draw_grid(axes[1], color='lightblue', alpha=0.2)
draw_basis_vectors(axes[1], np.array([1, 0]), np.array([0, 1]), 
                  color_i='lightgreen', color_j='lightcoral')
draw_basis_vectors(axes[1], i_new, j_new)

axes[1].text(i_new[0]+0.3, i_new[1]+0.3, "î lands here\n[1, 2]", 
            fontsize=11, color='green', fontweight='bold')
axes[1].text(j_new[0]+0.3, j_new[1]+0.3, "ĵ lands here\n[3, 0]", 
            fontsize=11, color='red', fontweight='bold')

# Show where v lands
setup_ax(axes[2], xlim=(-1, 10), ylim=(-1, 8), 
        title='Where does v = 3î + 2ĵ land?')
draw_grid(axes[2], color='lightblue', alpha=0.2)
draw_basis_vectors(axes[2], i_new, j_new)

# v lands at 3*i_new + 2*j_new
v_transformed = 3 * i_new + 2 * j_new

# Show the calculation visually
axes[2].arrow(0, 0, 3*i_new[0], 3*i_new[1], 
             head_width=0.2, head_length=0.2, 
             fc='green', ec='green', alpha=0.5, width=0.08)
axes[2].arrow(3*i_new[0], 3*i_new[1], 2*j_new[0], 2*j_new[1], 
             head_width=0.2, head_length=0.2, 
             fc='red', ec='red', alpha=0.5, width=0.08)

arrow_vt = FancyArrowPatch((0, 0), tuple(v_transformed), 
                          color='purple', arrowstyle='->', 
                          mutation_scale=20, linewidth=3)
axes[2].add_patch(arrow_vt)
axes[2].text(v_transformed[0]+0.3, v_transformed[1]+0.3, 
            f'v lands at\n{v_transformed}', 
            fontsize=14, color='purple', fontweight='bold')

axes[2].text(1.5, 2.5, '3î', fontsize=12, color='green', fontweight='bold')
axes[2].text(4.5, 6.5, '2ĵ', fontsize=12, color='red', fontweight='bold')

plt.tight_layout()
plt.show()

print("The Magic Formula:")
print(f"Original: v = 3î + 2ĵ = {v}")
print(f"After transformation: v = 3(new î) + 2(new ĵ)")
print(f"                       = 3{i_new} + 2{j_new}")
print(f"                       = {v_transformed}")
print("\n🎯 The coordinates stay the same! Only the basis vectors moved.")

4. From Transformation to Matrix

The Matrix Recipe

To create a matrix for a linear transformation:

  1. See where \(\hat{i} = \begin{bmatrix} 1 \\ 0 \end{bmatrix}\) lands → first column

  2. See where \(\hat{j} = \begin{bmatrix} 0 \\ 1 \end{bmatrix}\) lands → second column

\[\begin{split}\text{Matrix} = \begin{bmatrix} | & | \\ \hat{i}_{new} & \hat{j}_{new} \\ | & | \end{bmatrix}\end{split}\]

Example

If \(\hat{i} \rightarrow \begin{bmatrix} 3 \\ -2 \end{bmatrix}\) and \(\hat{j} \rightarrow \begin{bmatrix} 2 \\ 1 \end{bmatrix}\):

\[\begin{split}\text{Matrix} = \begin{bmatrix} 3 & 2 \\ -2 & 1 \end{bmatrix}\end{split}\]
# Build matrices from transformations
def transformation_to_matrix(i_new, j_new):
    """Convert transformed basis vectors to matrix"""
    return np.column_stack([i_new, j_new])

# Example transformations
transformations = {
    '90° Rotation': (
        np.array([0, 1]),  # î → [0, 1]
        np.array([-1, 0])  # ĵ → [-1, 0]
    ),
    'Shear': (
        np.array([1, 1]),  # î → [1, 1]
        np.array([0, 1])   # ĵ → [0, 1]
    ),
    'Stretch': (
        np.array([3, 0]),  # î → [3, 0]
        np.array([0, 2])   # ĵ → [0, 2]
    )
}

fig, axes = plt.subplots(2, 3, figsize=(18, 12))

for idx, (name, (i_new, j_new)) in enumerate(transformations.items()):
    # Before
    ax_before = axes[0, idx]
    setup_ax(ax_before, title=f'{name}: BEFORE')
    draw_grid(ax_before)
    draw_basis_vectors(ax_before, np.array([1, 0]), np.array([0, 1]))
    
    # Sample vector
    v = np.array([2, 1])
    arrow = FancyArrowPatch((0, 0), tuple(v), 
                           color='blue', arrowstyle='->', 
                           mutation_scale=15, linewidth=2)
    ax_before.add_patch(arrow)
    
    # After
    ax_after = axes[1, idx]
    setup_ax(ax_after, title=f'{name}: AFTER')
    draw_basis_vectors(ax_after, i_new, j_new)
    
    # Transform the grid
    matrix = transformation_to_matrix(i_new, j_new)
    for x in range(-5, 6):
        line_points = np.array([[x, y] for y in range(-5, 6)])
        transformed = line_points @ matrix.T
        ax_after.plot(transformed[:, 0], transformed[:, 1], 
                     'lightgray', alpha=0.5, linewidth=0.5)
    for y in range(-5, 6):
        line_points = np.array([[x, y] for x in range(-5, 6)])
        transformed = line_points @ matrix.T
        ax_after.plot(transformed[:, 0], transformed[:, 1], 
                     'lightgray', alpha=0.5, linewidth=0.5)
    
    # Transformed vector
    v_new = matrix @ v
    arrow_new = FancyArrowPatch((0, 0), tuple(v_new), 
                               color='blue', arrowstyle='->', 
                               mutation_scale=15, linewidth=2)
    ax_after.add_patch(arrow_new)
    
    # Display matrix
    matrix_str = f"Matrix =\n{matrix}"
    ax_after.text(0.02, 0.98, matrix_str, 
                 transform=ax_after.transAxes, 
                 fontsize=11, verticalalignment='top',
                 bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.8),
                 family='monospace')

plt.tight_layout()
plt.show()

print("Matrix Construction Rule:")
print("Column 1 = where î lands")
print("Column 2 = where ĵ lands")

5. Matrix-Vector Multiplication (Geometrically)

Don’t Memorize - Understand!

\[\begin{split}\begin{bmatrix} a & b \\ c & d \end{bmatrix} \begin{bmatrix} x \\ y \end{bmatrix} = x \begin{bmatrix} a \\ c \end{bmatrix} + y \begin{bmatrix} b \\ d \end{bmatrix}\end{split}\]

Interpretation

  1. The matrix columns tell you where \(\hat{i}\) and \(\hat{j}\) land

  2. The vector \(\begin{bmatrix} x \\ y \end{bmatrix}\) means \(x\hat{i} + y\hat{j}\)

  3. Multiply: take \(x\) of the first column + \(y\) of the second column

Example

\[\begin{split}\begin{bmatrix} 1 & 2 \\ -1 & 0 \end{bmatrix} \begin{bmatrix} 4 \\ 3 \end{bmatrix} = 4 \begin{bmatrix} 1 \\ -1 \end{bmatrix} + 3 \begin{bmatrix} 2 \\ 0 \end{bmatrix} = \begin{bmatrix} 10 \\ -4 \end{bmatrix}\end{split}\]
# Visualize matrix-vector multiplication
def visualize_matrix_vector_mult(matrix, vector):
    fig, axes = plt.subplots(1, 3, figsize=(20, 6))
    
    # Step 1: Original vector
    setup_ax(axes[0], xlim=(-1, 6), ylim=(-2, 5), 
            title=f'Step 1: Original v = {vector}')
    draw_grid(axes[0])
    draw_basis_vectors(axes[0], np.array([1, 0]), np.array([0, 1]))
    
    arrow_v = FancyArrowPatch((0, 0), tuple(vector), 
                             color='purple', arrowstyle='->', 
                             mutation_scale=20, linewidth=3)
    axes[0].add_patch(arrow_v)
    axes[0].text(vector[0]+0.3, vector[1]+0.3, 
                f'v = {vector[0]}î + {vector[1]}ĵ', 
                fontsize=13, color='purple', fontweight='bold')
    
    # Step 2: Show matrix transformation
    i_new = matrix[:, 0]
    j_new = matrix[:, 1]
    
    setup_ax(axes[1], title=f'Step 2: Matrix = {matrix.tolist()}')
    draw_grid(axes[1], color='lightblue', alpha=0.2)
    draw_basis_vectors(axes[1], np.array([1, 0]), np.array([0, 1]),
                      color_i='lightgreen', color_j='lightcoral')
    draw_basis_vectors(axes[1], i_new, j_new)
    
    axes[1].text(0.02, 0.98, 
                f"î → {i_new}\nĵ → {j_new}", 
                transform=axes[1].transAxes, 
                fontsize=12, verticalalignment='top',
                bbox=dict(boxstyle='round', facecolor='lightyellow'))
    
    # Step 3: Result
    result = matrix @ vector
    setup_ax(axes[2], title=f'Step 3: Result = {result}')
    draw_basis_vectors(axes[2], i_new, j_new)
    
    # Show linear combination
    comp1 = vector[0] * i_new
    comp2 = vector[1] * j_new
    
    axes[2].arrow(0, 0, comp1[0], comp1[1], 
                 head_width=0.15, head_length=0.15, 
                 fc='green', ec='green', alpha=0.5, width=0.05)
    axes[2].text(comp1[0]/2, comp1[1]/2-0.3, 
                f'{vector[0]}î', fontsize=11, color='green')
    
    axes[2].arrow(comp1[0], comp1[1], comp2[0], comp2[1], 
                 head_width=0.15, head_length=0.15, 
                 fc='red', ec='red', alpha=0.5, width=0.05)
    axes[2].text(comp1[0]+comp2[0]/2+0.2, comp1[1]+comp2[1]/2, 
                f'{vector[1]}ĵ', fontsize=11, color='red')
    
    arrow_result = FancyArrowPatch((0, 0), tuple(result), 
                                  color='purple', arrowstyle='->', 
                                  mutation_scale=20, linewidth=3)
    axes[2].add_patch(arrow_result)
    axes[2].text(result[0]+0.3, result[1]+0.3, 
                f'Result\n{result}', 
                fontsize=13, color='purple', fontweight='bold')
    
    plt.tight_layout()
    plt.show()
    
    print(f"\nMatrix-Vector Multiplication:")
    print(f"Matrix @ Vector = {matrix} @ {vector}")
    print(f"               = {vector[0]} * {i_new} + {vector[1]} * {j_new}")
    print(f"               = {comp1} + {comp2}")
    print(f"               = {result}")

# Example
M = np.array([[1, 2], [-1, 0]])
v = np.array([4, 3])
visualize_matrix_vector_mult(M, v)

6. Common Linear Transformations

Summary

The Big Ideas

  1. Linear transformations = functions that keep lines straight and fix the origin

  2. Matrices = numerical representation of linear transformations

  3. Matrix columns = where basis vectors land

  4. Matrix-vector multiplication = applying the transformation

The Mental Picture

When you see \(\begin{bmatrix} a & b \\ c & d \end{bmatrix}\), think:

\(\hat{i}\) goes to \(\begin{bmatrix} a \\ c \end{bmatrix}\) and \(\hat{j}\) goes to \(\begin{bmatrix} b \\ d \end{bmatrix}\)

When you see \(A\vec{v}\), think:

“Where does \(\vec{v}\) land after the transformation described by \(A\)?”

Next Steps

Now we understand single transformations. What about doing two transformations in a row? That’s matrix multiplication!

Exercises

  1. Draw what happens to the unit square under the transformation \(\begin{bmatrix} 2 & 1 \\ 0 & 1 \end{bmatrix}\)

  2. Find the matrix for a 180° rotation

  3. What transformation does \(\begin{bmatrix} 0 & 0 \\ 0 & 0 \end{bmatrix}\) represent?

  4. Compute \(\begin{bmatrix} 3 & 1 \\ 2 & 2 \end{bmatrix} \begin{bmatrix} 2 \\ 5 \end{bmatrix}\) geometrically

  5. Find the matrix that reflects across the line \(y = -x\)