Theory NotebookMath for LLMs

Vectors and Spaces

Linear Algebra Basics / Vectors and Spaces

Run notebook
Private notes
0/8000

Notes stay private to your browser until account sync is configured.

Theory Notebook

Theory Notebook

Converted from theory.ipynb for web reading.

Vectors and Spaces - Theory & Implementations

This notebook follows the same chapter path as notes.md, but in the order that is easiest to learn by doing:

  1. Concrete vectors in R^n
  2. Linear combinations, span, independence, basis, and dimension
  3. Norms, inner products, orthogonality, and projections
  4. Affine/convex geometry and the probability simplex
  5. High-dimensional geometry and random projection
  6. Linear maps, rank-nullity, and the four fundamental subspaces
  7. AI-specific spaces: embeddings, attention subspaces, LoRA, NTK
  8. Regularization geometry: L1, L2, and spectral control

The notebook uses only numpy and optional matplotlib. It is intentionally code-first: the goal is to make the linear algebra feel operational.

Primary references behind the chapter: Axler, Vershynin, Mikolov et al. (2013), Vaswani et al. (2017), Ethayarajh (2019), Hu et al. (2022), Jacot et al. (2018), Miyato et al. (2018), and Amari (1998).

Code cell 2

import numpy as np
import matplotlib.pyplot as plt
import matplotlib as mpl

try:
    import seaborn as sns
    sns.set_theme(style="whitegrid", palette="colorblind")
    HAS_SNS = True
except ImportError:
    plt.style.use("seaborn-v0_8-whitegrid")
    HAS_SNS = False

mpl.rcParams.update({
    "figure.figsize":    (10, 6),
    "figure.dpi":         120,
    "font.size":           13,
    "axes.titlesize":      15,
    "axes.labelsize":      13,
    "xtick.labelsize":     11,
    "ytick.labelsize":     11,
    "legend.fontsize":     11,
    "legend.framealpha":   0.85,
    "lines.linewidth":      2.0,
    "axes.spines.top":     False,
    "axes.spines.right":   False,
    "savefig.bbox":       "tight",
    "savefig.dpi":         150,
})
np.random.seed(42)
print("Plot setup complete.")

Code cell 3

import numpy as np
import numpy.linalg as la
import scipy.linalg as sla
from scipy import stats

COLORS = {
    "primary": "#0077BB",
    "secondary": "#EE7733",
    "tertiary": "#009988",
    "error": "#CC3311",
    "neutral": "#555555",
    "highlight": "#EE3377",
}
HAS_MPL = True
np.set_printoptions(precision=8, suppress=True)
np.random.seed(42)

def header(title):
    print("\n" + "=" * len(title))
    print(title)
    print("=" * len(title))

def check_true(name, cond):
    ok = bool(cond)
    print(f"{'PASS' if ok else 'FAIL'} - {name}")
    return ok

def check_close(name, got, expected, tol=1e-8):
    ok = np.allclose(got, expected, atol=tol, rtol=tol)
    print(f"{'PASS' if ok else 'FAIL'} - {name}: got {got}, expected {expected}")
    return ok

def check(name, got, expected, tol=1e-8):
    return check_close(name, got, expected, tol=tol)

def softmax(z, axis=-1, tau=1.0):
    z = np.asarray(z, dtype=float) / float(tau)
    z = z - np.max(z, axis=axis, keepdims=True)
    e = np.exp(z)
    return e / np.sum(e, axis=axis, keepdims=True)

def cosine_similarity(a, b):
    a = np.asarray(a, dtype=float); b = np.asarray(b, dtype=float)
    return float(a @ b / (la.norm(a) * la.norm(b) + 1e-12))

def numerical_rank(A, tol=1e-10):
    return int(np.sum(la.svd(A, compute_uv=False) > tol))

def orthonormal_basis(A, tol=1e-10):
    Q, R = la.qr(A)
    keep = np.abs(np.diag(R)) > tol
    return Q[:, keep]

def null_space(A, tol=1e-10):
    U, S, Vt = la.svd(A)
    return Vt[S.size:,:].T if S.size < Vt.shape[0] else Vt[S <= tol,:].T



# Compatibility helpers used by the Chapter 02 theory and exercise cells.
def null_space(A, tol=1e-10):
    A = np.asarray(A, dtype=float)
    U, S, Vt = la.svd(A, full_matrices=True)
    rank = int(np.sum(S > tol))
    return Vt[rank:].T

svd_null_space = null_space

def gram_schmidt(vectors, tol=1e-10):
    A = np.asarray(vectors, dtype=float)
    if A.ndim == 1:
        A = A.reshape(1, -1)
    basis = []
    for v in A:
        w = v.astype(float).copy()
        for q in basis:
            w = w - np.dot(w, q) * q
        norm = la.norm(w)
        if norm > tol:
            basis.append(w / norm)
    return np.array(basis)

def projection_matrix_from_columns(A, tol=1e-10):
    Q = orthonormal_basis(np.asarray(A, dtype=float), tol=tol)
    return Q @ Q.T


def random_unit_vectors(n, d):
    X = np.random.randn(n, d)
    return X / np.maximum(la.norm(X, axis=1, keepdims=True), 1e-12)

def pairwise_distances(X):
    X = np.asarray(X, dtype=float)
    diff = X[:, None, :] - X[None, :, :]
    return la.norm(diff, axis=-1)


def normalize(x, axis=None, tol=1e-12):
    x = np.asarray(x, dtype=float)
    norm = la.norm(x, axis=axis, keepdims=True)
    return x / np.maximum(norm, tol)

def frobenius_inner(A, B):
    return float(np.sum(np.asarray(A, dtype=float) * np.asarray(B, dtype=float)))

def outer_sum_product(A, B):
    A = np.asarray(A, dtype=float)
    B = np.asarray(B, dtype=float)
    return sum(np.outer(A[:, k], B[k, :]) for k in range(A.shape[1]))

def softmax_rows(X):
    return softmax(X, axis=1)

def col_space(A, tol=1e-10):
    return orthonormal_basis(np.asarray(A, dtype=float), tol=tol)

def row_space(A, tol=1e-10):
    return orthonormal_basis(np.asarray(A, dtype=float).T, tol=tol).T

def rref(A, tol=1e-10):
    R = np.array(A, dtype=float, copy=True)
    m, n = R.shape
    pivots = []
    row = 0
    for col in range(n):
        pivot = row + int(np.argmax(np.abs(R[row:, col]))) if row < m else row
        if row >= m or abs(R[pivot, col]) <= tol:
            continue
        if pivot != row:
            R[[row, pivot]] = R[[pivot, row]]
        R[row] = R[row] / R[row, col]
        for r in range(m):
            if r != row:
                R[r] = R[r] - R[r, col] * R[row]
        pivots.append(col)
        row += 1
        if row == m:
            break
    R[np.abs(R) < tol] = 0.0
    return R, pivots

def nullspace_basis(A, tol=1e-10):
    A = np.asarray(A, dtype=float)
    U, S, Vt = la.svd(A, full_matrices=True)
    rank = int(np.sum(S > tol))
    return Vt[rank:].T, rank

print("Chapter helper setup complete.")

1. Concrete Vectors in R^n

Start concrete. If vector addition, scaling, linear combinations, and angles are not automatic yet, abstract vector spaces will feel harder than they need to.

We begin with the standard Euclidean setting and then reuse the same algebra everywhere else.

Code cell 5

# === 1.1 Basic vector operations ===
u = np.array([1.0, 2.0, -1.0])
v = np.array([3.0, 0.0, 1.0])
alpha = -2.5

print("u =", u)
print("v =", v)
print("u + v =", u + v)
print("alpha * u =", alpha * u)
print("u dot v =", np.dot(u, v))
print("||u||_2 =", np.linalg.norm(u))
print("||v||_2 =", np.linalg.norm(v))
print("cos(u, v) =", cosine_similarity(u, v))

angle_radians = np.arccos(np.clip(cosine_similarity(u, v), -1.0, 1.0))
angle_degrees = np.degrees(angle_radians)
print("angle(u, v) in degrees =", angle_degrees)

# Linear combination
w = 2 * u - 0.5 * v
print("w = 2u - 0.5v =", w)

Code cell 6

# === 1.2 Span, linear independence, and abstract examples ===
independent = np.column_stack([
    np.array([1.0, 0.0, 0.0]),
    np.array([0.0, 1.0, 0.0]),
    np.array([0.0, 0.0, 1.0])
])

dependent = np.column_stack([
    np.array([1.0, 2.0]),
    np.array([2.0, 4.0])
])

print("rank(independent columns) =", np.linalg.matrix_rank(independent))
print("rank(dependent columns) =", np.linalg.matrix_rank(dependent))

target = np.array([7.0, 4.0])
standard_basis = np.column_stack([
    np.array([1.0, 0.0]),
    np.array([0.0, 1.0])
])
coords, *_ = np.linalg.lstsq(standard_basis, target, rcond=None)
print("coordinates of", target, "in the standard basis =", coords)

# Polynomials also form a vector space if we represent them by coefficients.
# p(x) = 1 + 2x - x^2  -> [1, 2, -1]
# q(x) = 3 - x + 4x^2 -> [3, -1, 4]
p = np.array([1.0, 2.0, -1.0])
q = np.array([3.0, -1.0, 4.0])
print("p + q coefficients =", p + q)
print("3p coefficients =", 3 * p)

# Function spaces also behave linearly under pointwise operations.
x = np.linspace(0.0, 1.0, 5)
f = x**2
g = np.sin(np.pi * x)
print("(f + g)(x) sampled =", f + g)
print("(2f - g)(x) sampled =", 2 * f - g)

2. Basis, Coordinates, and Dimension

A basis is the minimal non-redundant coordinate system for a space: independent enough to avoid redundancy, rich enough to span everything.

Learning move: compute coordinates in a non-standard basis and reconstruct the original vector.

Code cell 8

# === 2.1 Coordinates in a non-standard basis ===
B = np.column_stack([
    np.array([1.0, 1.0]),
    np.array([1.0, -1.0])
])
x = np.array([4.0, 2.0])

coords_in_B = np.linalg.solve(B, x)
reconstructed = B @ coords_in_B

print("basis matrix B =\n", B)
print("vector x =", x)
print("[x]_B =", coords_in_B)
print("reconstructed from basis coordinates =", reconstructed)

# Change of basis idea: the vector is unchanged, only coordinates change.
standard_coords = x
print("[x]_standard =", standard_coords)
print("dimension of the space =", B.shape[0])

3. Norms, Metrics, and Size

Norms measure size. Metrics measure distance. In finite dimensions all norms are equivalent in the topological sense, but they induce very different optimization geometry.

For AI, this is exactly why L1, L2, and L-infinity constraints lead to different behavior.

Code cell 10

# === 3.1 Vector and matrix norms ===
v = np.array([3.0, -4.0, 0.0, 1.0])

l1 = np.linalg.norm(v, 1)
l2 = np.linalg.norm(v, 2)
linf = np.linalg.norm(v, np.inf)

print("v =", v)
print("||v||_1 =", l1)
print("||v||_2 =", l2)
print("||v||_inf =", linf)
print("Inequalities hold:", linf <= l2 <= l1)

M = np.array([[2.0, 1.0], [1.0, 2.0]])
singular_values = np.linalg.svd(M, compute_uv=False)
fro = np.linalg.norm(M, 'fro')
spec = singular_values[0]

print("\nM =\n", M)
print("singular values =", singular_values)
print("||M||_F =", fro)
print("||M||_2 =", spec)

# Random check of norm inequalities on several samples.
ok = True
for _ in range(100):
    z = np.random.randn(16)
    ok &= np.linalg.norm(z, np.inf) <= np.linalg.norm(z, 2) + 1e-12
    ok &= np.linalg.norm(z, 2) <= np.linalg.norm(z, 1) + 1e-12
print("Random finite-dimensional norm checks passed:", ok)

4. Inner Products, Orthogonality, and Projection

Once a space has an inner product, it has geometry: angle, orthogonality, and best approximation.

This is the section to internalize if you want attention, least squares, and signal decomposition to feel natural.

Code cell 12

# === 4.1 Gram-Schmidt and orthonormalization ===
vectors = np.array([
    [1.0, 1.0, 0.0],
    [1.0, 0.0, 1.0],
    [0.0, 1.0, 1.0]
])
Q = gram_schmidt(vectors)

print("orthonormal basis from Gram-Schmidt =\n", Q)
print("Q Q^T =\n", Q @ Q.T)

# === 4.2 Projection onto a line ===
u = np.array([1.0, 2.0, 2.0])
P_line = np.outer(u, u) / np.dot(u, u)
x = np.array([3.0, -1.0, 4.0])
proj_x = P_line @ x
resid_x = x - proj_x

print("\nprojection matrix onto span(u) =\n", P_line)
print("P^2 == P:", np.allclose(P_line @ P_line, P_line))
print("P^T == P:", np.allclose(P_line.T, P_line))
print("projection of x onto span(u) =", proj_x)
print("residual =", resid_x)
print("residual orthogonal to u:", np.allclose(np.dot(resid_x, u), 0.0))

# === 4.3 Projection onto a column space and least squares ===
A = np.array([
    [1.0, 0.0],
    [0.0, 1.0],
    [1.0, 1.0]
])
P_A = projection_matrix_from_columns(A)
b = np.array([2.0, 1.0, 4.0])
proj_b = P_A @ b
resid_b = b - proj_b
x_star, *_ = np.linalg.lstsq(A, b, rcond=None)

print("\nprojection onto col(A) =\n", P_A)
print("rank(P_A) =", np.linalg.matrix_rank(P_A))
print("projected b =", proj_b)
print("least-squares coefficients x* =", x_star)
print("A x* =", A @ x_star)
print("residual orthogonal to columns of A:", np.allclose(A.T @ resid_b, 0.0))

5. Affine Spaces, Convexity, and the Probability Simplex

Subspaces pass through the origin. Affine spaces are translated subspaces. Convex sets contain line segments. The probability simplex is the most important convex set in language modeling.

Attention outputs live in convex hulls because attention weights are nonnegative and sum to one.

Code cell 14

# === 5.1 Convex combinations and simplex geometry ===
values = np.array([
    [2.0, 0.0],
    [0.0, 2.0],
    [1.0, 1.0]
])
logits = np.array([2.5, 1.0, -0.5])

for tau in [0.2, 1.0, 5.0]:
    weights = softmax(logits, tau=tau)
    output = weights @ values
    print(f"tau={tau:>3}: weights={weights}, output={output}, sum(weights)={weights.sum():.4f}")

# Simplex points stay in the simplex under convex combinations.
p = np.array([0.7, 0.2, 0.1])
q = np.array([0.1, 0.3, 0.6])
lam = 0.35
r = lam * p + (1 - lam) * q
print("\np =", p)
print("q =", q)
print("convex combination r =", r)
print("r is in simplex:", np.all(r >= 0) and np.isclose(r.sum(), 1.0))

# Affine example: x + y + z = 1
a = np.array([1.0, 0.0, 0.0])
b = np.array([0.0, 1.0, 0.0])
mid = 0.5 * a + 0.5 * b
print("\naffine slice example point:", mid, "sum =", mid.sum())

6. High-Dimensional Geometry

This is where low-dimensional intuition breaks.

Random high-dimensional vectors are usually nearly orthogonal. Distances concentrate. Random projections can preserve geometry much better than intuition suggests. These facts are central to embedding spaces and approximate nearest-neighbor retrieval.

Code cell 16

# === 6.1 Random vectors become nearly orthogonal ===
for d in [2, 10, 100, 1000]:
    X = random_unit_vectors(400, d)
    G = X @ X.T
    upper = G[np.triu_indices_from(G, k=1)]
    print(f"d={d:>4} | mean cosine={upper.mean(): .4f} | std cosine={upper.std(): .4f}")

# === 6.2 Johnson-Lindenstrauss style random projection ===
n_points = 120
d_original = 500
d_projected = 80

X = np.random.randn(n_points, d_original)
R = np.random.randn(d_original, d_projected) / np.sqrt(d_projected)
Y = X @ R

D_X = pairwise_distances(X[:40])
D_Y = pairwise_distances(Y[:40])
mask = np.triu(np.ones_like(D_X, dtype=bool), k=1)
relative_error = np.abs(D_Y[mask] - D_X[mask]) / np.maximum(D_X[mask], 1e-12)

print("\nRandom projection distance preservation summary")
print("median relative error =", np.median(relative_error))
print("90th percentile relative error =", np.percentile(relative_error, 90))
print("max relative error =", np.max(relative_error))

if HAS_MPL:
    X2 = random_unit_vectors(500, 2)
    X100 = random_unit_vectors(500, 100)
    c2 = (X2 @ X2.T)[np.triu_indices(500, 1)]
    c100 = (X100 @ X100.T)[np.triu_indices(500, 1)]

    fig, axes = plt.subplots(1, 2, figsize=(12, 4))
    axes[0].hist(c2, bins=40, color='steelblue', alpha=0.8)
    axes[0].set_title('Cosine distribution in d=2')
    axes[1].hist(c100, bins=40, color='darkorange', alpha=0.8)
    axes[1].set_title('Cosine distribution in d=100')
    for ax in axes:
        ax.set_xlabel('cosine similarity')
        ax.set_ylabel('count')
    plt.tight_layout()
    plt.show()

7. Linear Maps, Rank-Nullity, and the Four Fundamental Subspaces

A linear map is a geometry-preserving algebraic rule. The kernel tells you what is lost. The image tells you what can be produced. Rank-nullity counts both exactly.

This section is the cleanest bridge between vectors and matrices.

Code cell 18

# === 7.1 Null space and row space are orthogonal complements ===
# (Four fundamental subspaces — canonical treatment in 06-Vector-Spaces-Subspaces)

import numpy as np

A = np.array([
    [1.0, 2.0, 3.0],
    [2.0, 4.0, 6.0],   # row 2 = 2 * row 1  (rank-deficient)
    [1.0, 1.0, 2.0]
])

# Rank and nullity
rank    = np.linalg.matrix_rank(A)
nullity = A.shape[1] - rank
print(f'Shape: {A.shape},  rank = {rank},  nullity = {nullity}')
print(f'rank + nullity = {rank} + {nullity} = {rank + nullity} = n  (rank-nullity theorem)')

# A vector in the null space
_, _, Vt = np.linalg.svd(A, full_matrices=True)
null_vec = Vt[-1]   # last right singular vector spans null(A)
print(f'\nnull_vec = {null_vec.round(4)}')
print(f'A @ null_vec = {(A @ null_vec).round(10)}  (should be ~0)')

# A vector in the row space
row_vec = Vt[0]     # first right singular vector spans row(A)
print(f'row_vec  = {row_vec.round(4)}')

# Orthogonality: null(A) perp row(A)
dot = float(null_vec @ row_vec)
print(f'\nnull_vec . row_vec = {dot:.2e}  (numerically zero — they are orthogonal)')

# Forward reference
print('\n-> Full four-subspace theory (col, row, null, left-null) with bases and')
print('   dimensional identities: 06-Vector-Spaces-Subspaces Section 7.')

8. AI-Specific Spaces: Embeddings, Attention, Low Rank, and NTK

This is the point of the chapter from an AI perspective.

  • Embeddings are vectors in learned spaces.
  • Attention heads operate in projected subspaces.
  • LoRA uses low-rank updates in matrix space.
  • NTK treats training through inner products of parameter gradients.

The examples below are deliberately small, but the geometry is the same as in large models.

Code cell 20

# === 8.1 Synthetic embedding geometry ===
tokens = ["king", "queen", "man", "woman", "apple"]
E = np.array([
    [0.8, 0.9, 0.2, 0.1],
    [0.8, 0.9, -0.2, 0.1],
    [0.7, 0.8, 0.4, 0.1],
    [0.7, 0.8, -0.4, 0.1],
    [-0.3, 0.2, 0.0, 0.9]
], dtype=float)

def nearest(vec, names, matrix):
    sims = [cosine_similarity(vec, row) for row in matrix]
    order = np.argsort(sims)[::-1]
    return [(names[i], float(sims[i])) for i in order[:3]]

analogy = E[0] - E[2] + E[3]  # king - man + woman
print("Nearest tokens to king - man + woman:")
for name, sim in nearest(analogy, tokens, E):
    print(f"  {name:>5} | cosine={sim:.4f}")

# === 8.2 Attention interaction rank is bounded by head dimension ===
d = 32
d_k = 6
W_Q = np.random.randn(d, d_k)
W_K = np.random.randn(d, d_k)
interaction = W_Q @ W_K.T
print("\nrank(W_Q W_K^T) =", np.linalg.matrix_rank(interaction))
print("head dimension bound d_k =", d_k)

# === 8.3 LoRA-style low-rank update ===
r = 3
B = np.random.randn(d, r)
A_small = np.random.randn(r, d)
delta_W = B @ A_small
print("rank(delta_W) <=", r, "and observed rank =", np.linalg.matrix_rank(delta_W))

# === 8.4 NTK for a linear model ===
# For f_theta(x) = theta^T x, grad_theta f(x) = x, so NTK(x, x') = x^T x'.
X = np.array([
    [-1.0, 0.0],
    [-0.2, 1.0],
    [0.4, 1.2],
    [1.1, -0.3]
])
K = X @ X.T
print("\nLinear-model NTK Gram matrix =\n", K)

# === 8.5 Softmax geometry in vocabulary space ===
logits = np.array([4.0, 1.5, 0.2, -0.5])
for tau in [0.25, 1.0, 3.0]:
    p = softmax(logits, tau=tau)
    print(f"tau={tau:>4}: probs={p}, entropy={-np.sum(p * np.log(p + 1e-12)):.4f}")

9. Regularization Geometry

L2 regularization shrinks smoothly toward the origin. L1 regularization prefers corners and therefore sparsity. Spectral normalization constrains the maximum stretch of a linear map.

This is best understood geometrically rather than heuristically.

Code cell 22

# === 9.1 L1 vs L2 geometry for a simple linear objective ===
g = np.array([1.0, 0.35])
g = normalize(g)

# On the unit L2 ball, the maximizer of g^T theta is g itself.
theta_l2 = g

# On the unit L1 ball in 2D, the maximizer lands on a corner aligned with the largest magnitude coordinate.
theta_l1 = np.array([1.0, 0.0]) if abs(g[0]) >= abs(g[1]) else np.array([0.0, 1.0])
theta_l1 *= np.sign(g[np.argmax(np.abs(g))])

print("gradient direction g =", g)
print("best point on unit L2 ball =", theta_l2)
print("best point on unit L1 ball =", theta_l1)
print("objective value on L2 ball =", float(g @ theta_l2))
print("objective value on L1 ball =", float(g @ theta_l1))

# Spectral norm as maximum stretch.
W = np.array([[2.0, 1.0], [0.0, 1.0]])
sigma_max = np.linalg.svd(W, compute_uv=False)[0]
print("\nW =\n", W)
print("spectral norm of W =", sigma_max)

if HAS_MPL:
    t = np.linspace(0, 2 * np.pi, 400)
    l2_x = np.cos(t)
    l2_y = np.sin(t)
    diamond = np.array([
        [1.0, 0.0],
        [0.0, 1.0],
        [-1.0, 0.0],
        [0.0, -1.0],
        [1.0, 0.0]
    ])

    fig, ax = plt.subplots(figsize=(6, 6))
    ax.plot(l2_x, l2_y, label='L2 unit ball', linewidth=2)
    ax.plot(diamond[:, 0], diamond[:, 1], label='L1 unit ball', linewidth=2)
    ax.arrow(0, 0, g[0], g[1], width=0.02, color='black', length_includes_head=True, label='gradient')
    ax.scatter(*theta_l2, color='tab:blue', s=80, label='L2 optimum')
    ax.scatter(*theta_l1, color='tab:orange', s=80, label='L1 optimum')
    ax.set_xlim(-1.3, 1.3)
    ax.set_ylim(-1.3, 1.3)
    ax.set_aspect('equal')
    ax.grid(True, alpha=0.3)
    ax.legend()
    ax.set_title('Why L1 prefers corners and L2 does not')
    plt.show()

Key Takeaways

  • Vectors are not just arrays. They are elements of spaces with algebra and geometry.
  • Basis and dimension tell you how much independent structure a space contains.
  • Norms, inner products, and projections are the operational language of optimization and representation.
  • High-dimensional geometry explains why random vectors are nearly orthogonal and why random projection works.
  • Linear maps are best understood through kernel, image, rank, and nullity.
  • Transformers are fundamentally vector-space machines: embeddings, attention, residual streams, and low-rank updates all live in this language.

Suggested next step: go directly to 02-Matrix-Operations/theory.ipynb and reinterpret matrices as concrete representations of the linear maps studied here.

Useful references for this notebook:

Skill Check

Test this lesson

Answer 4 quick questions to lock in the lesson and feed your adaptive practice queue.

--
Score
0/4
Answered
Not attempted
Status
1

Which module does this lesson belong to?

2

Which section is covered in this lesson content?

3

Which term is most central to this lesson?

4

What is the best way to use this lesson for real learning?

Your answers save locally first, then sync when account storage is available.
Practice queue