Skip to content

NumPy for Mathematics

Every page in this module checks its mathematics by running it. That habit is worth more than any single result on any single page: a derivation you have verified numerically is a derivation you believe, and a derivation that disagrees with the code has a bug in one of the two — which is information.

This page is the toolkit for doing that, and the four traps that make a correct derivation print a wrong number.

  • Which NumPy object is a vector and which is a matrix, and why the distinction bites.
  • @ versus *, the single most common source of silently wrong results.
  • Broadcasting, in the three rules that actually determine what happens.
  • axis, read as “the index being eliminated”.
  • The np.linalg functions this module uses, and which to reach for when.
  • Why == is the wrong way to compare floats, and what condition number tells you before you trust an answer.

Intuition: an array is a grid, and its shape is the mathematics

Section titled “Intuition: an array is a grid, and its shape is the mathematics”

NumPy’s central object is the ndarray, and its .shape is where the mathematics lives. A shape (3,) array is a vector in R3\mathbb{R}^3; a shape (3, 2) array is a matrix in R3×2\mathbb{R}^{3\times 2}. Most NumPy bugs are shape bugs, and most shape bugs come from one of two things: confusing (3,) with (3, 1), or expecting * to do what @ does.

diagram Diagram mermaid

That right-hand branch is the trap worth internalising before anything else. A column and a row broadcast against each other into a matrix, so x - y with mismatched orientations gives you a 3×33\times3 array where you expected a length-3 vector — and no error is raised. It shows up later as a loss that will not go down.

shapes.py
import numpy as np
 
x = np.array([1.0, 2.0, 3.0])          # shape (3,)   — a vector
A = np.array([[1.0, 2.0],
              [3.0, 4.0],
              [5.0, 6.0]])             # shape (3, 2) — a matrix
 
print(x.shape, A.shape, A.ndim, A.dtype)
print("column:", x.reshape(-1, 1).shape, " row:", x.reshape(1, -1).shape)
print("transpose of a 1-D array does nothing:", x.T.shape)
text
(3,) (3, 2) 2 float64
column: (3, 1)  row: (1, 3)
transpose of a 1-D array does nothing: (3,)

That last line surprises people. Mathematically x\mathbf{x}^\top is a row vector, but a 1-D NumPy array has no orientation to flip, so .T is a no-op. If you need a genuine column, reshape to (n, 1).

@ is matrix multiplication; * is elementwise

Section titled “@ is matrix multiplication; * is elementwise”
xy=ixiyiversus(xy)i=xiyi\mathbf{x}^\top\mathbf{y} = \sum_i x_i y_i \qquad\text{versus}\qquad (\mathbf{x} \odot \mathbf{y})_i = x_i y_i

The first is a scalar; the second is a vector. NumPy writes them x @ y and x * y.

products.py
import numpy as np
 
x = np.array([1.0, 2.0, 3.0])
y = np.array([4.0, 5.0, 6.0])
 
print("x @ y  (dot, a scalar) :", x @ y)
print("x * y  (elementwise)   :", x * y)
print("outer product shape    :", np.outer(x, y).shape)
 
A = np.array([[1.0, 2.0], [3.0, 4.0]])
B = np.array([[5.0, 6.0], [7.0, 8.0]])
print("A @ B  (matrix product):\n", A @ B)
print("A * B  (elementwise)   :\n", A * B)
print("AB != BA :", not np.allclose(A @ B, B @ A))
text
x @ y  (dot, a scalar) : 32.0
x * y  (elementwise)   : [ 4. 10. 18.]
outer product shape    : (3, 3)
A @ B  (matrix product):
 [[19. 22.]
 [43. 50.]]
A * B  (elementwise)   :
 [[ 5. 12.]
 [21. 32.]]
AB != BA : True

A * B runs without complaint and returns something of the right shape. That is why the mistake survives: nothing fails, the numbers are just not the ones the mathematics asked for. §2.2 makes the same point with the same example — matrix multiplication is composition of maps, not entry-by-entry arithmetic — and this is what it looks like in code.

Three rules, applied right to left over the shapes:

  1. If the arrays have different numbers of dimensions, the shorter shape is padded with 11s on the left.
  2. Two dimensions are compatible if they are equal, or if one of them is 1.
  3. A dimension of size 11 is stretched to match the other.
broadcasting.py
import numpy as np
 
A = np.arange(6.0).reshape(3, 2)      # (3, 2)
row = np.array([10.0, 20.0])          # (2,)   -> padded to (1,2) -> stretched to (3,2)
col = np.array([[1.0], [2.0], [3.0]]) # (3, 1) -> stretched to (3,2)
 
print("A + row:\n", A + row)
print("A + col:\n", A + col)
 
# Centring a dataset: the classic legitimate use.
X = np.array([[1.0, 10.0], [2.0, 20.0], [3.0, 30.0]])
print("column means:", X.mean(axis=0))
print("centred:\n", X - X.mean(axis=0))
 
# The trap: a column and a row broadcast to a MATRIX.
u = np.array([1.0, 2.0, 3.0])
print("u.reshape(-1,1) - u.reshape(1,-1) has shape:",
      (u.reshape(-1, 1) - u.reshape(1, -1)).shape, "-- not (3,)!")
text
A + row:
 [[10. 21.]
 [12. 23.]
 [14. 25.]]
A + col:
 [[1. 2.]
 [4. 5.]
 [7. 8.]]
column means: [ 2. 20.]
centred:
 [[ -1. -10.]
 [  0.   0.]
 [  1.  10.]]
u.reshape(-1,1) - u.reshape(1,-1) has shape: (3, 3) -- not (3,)!

The (3, 3) on the last line is the pairwise-difference matrix, which is genuinely useful when you want it — it is how you build a distance matrix in one line. It is a bug only when you wanted a vector, and NumPy cannot tell which you meant.

axis=0    iaijaxis=1    jaij\text{axis}=0 \;\leftrightarrow\; \sum_{i} a_{ij} \qquad \text{axis}=1 \;\leftrightarrow\; \sum_{j} a_{ij}

Read axis=0 as “sum away the first index”, leaving one number per column. The alternative phrasing — “sum along the rows” — is ambiguous, and that ambiguity is why this is the most reversed argument in NumPy.

axes.py
import numpy as np
 
A = np.arange(6.0).reshape(3, 2)
print("A:\n", A)
print("axis=0 (eliminate rows)   -> one per column:", A.sum(axis=0))
print("axis=1 (eliminate cols)   -> one per row   :", A.sum(axis=1))
print("no axis                   -> everything    :", A.sum())
print("keepdims keeps the rank, for broadcasting  :", A.sum(axis=1, keepdims=True).shape)
text
A:
 [[0. 1.]
 [2. 3.]
 [4. 5.]]
axis=0 (eliminate rows)   -> one per column: [6. 9.]
axis=1 (eliminate cols)   -> one per row   : [1. 5. 9.]
no axis                   -> everything    : 15.0
keepdims keeps the rank, for broadcasting  : (3, 1)

keepdims=True is the fix for half of all broadcasting accidents: it keeps the reduced axis as size 11 so the result still lines up against the original array.

you wantcallused in
solve Ax=b\mathbf{A}\mathbf{x}=\mathbf{b}np.linalg.solve(A, b)§2.3
least squares, any shapenp.linalg.lstsq(A, b, rcond=None)§3.8, Ch 9
inversenp.linalg.inv(A)§2.2 — but see the warning below
pseudo-inversenp.linalg.pinv(A)§2.3, §3.8
determinantnp.linalg.det(A)§4.1
eigenvalues and vectorsnp.linalg.eig(A), eigh if symmetric§4.2, §4.4
singular value decompositionnp.linalg.svd(A)§4.5, §4.6, Ch 10
normnp.linalg.norm(x, ord=...)§3.1
ranknp.linalg.matrix_rank(A)§2.6
condition numbernp.linalg.cond(A)§2.3, §7.1
Cholesky factornp.linalg.cholesky(A)§4.3, §6.5
tracenp.trace(A)§4.1

Four facts, and each one has bitten someone on a page in this module.

1. float64 has about 15–16 significant decimal digits. float32 has about 7. NumPy defaults to float64 for Python floats but to float32 in most deep-learning frameworks, which is why a gradient check that passes in NumPy can fail in PyTorch.

2. == is the wrong comparison. 0.1+0.20.30.1 + 0.2 \neq 0.3 in binary floating point. Use np.isclose or np.allclose, which compare within a tolerance.

3. Subtracting nearly equal numbers destroys precision. This is catastrophic cancellation, and it is why the numerical derivative in the calculus refresher gets worse below h106h \approx 10^{-6}.

4. Order of operations changes the answer. Floating-point addition is not associative, so (a + b) + c and a + (b + c) can differ. Summing a million numbers in a different order gives a different total, and NumPy’s pairwise summation is more accurate than a naive loop for exactly this reason.

floating_point.py
import numpy as np
 
print("0.1 + 0.2 == 0.3 :", 0.1 + 0.2 == 0.3)
print("difference       :", 0.1 + 0.2 - 0.3)
print("isclose          :", np.isclose(0.1 + 0.2, 0.3))
 
print("float64 eps:", np.finfo(np.float64).eps)
print("float32 eps:", np.finfo(np.float32).eps)
 
# Catastrophic cancellation: 15 digits agree, so 15 digits are destroyed.
a, b = 1.0, 1.0 + 1e-15
print("relative error of the difference:", abs((b - a) - 1e-15) / 1e-15)
 
# Addition is not associative.
xs = np.array([1.0, 1e16, -1e16])
print("left to right:", (xs[0] + xs[1]) + xs[2], " regrouped:", xs[0] + (xs[1] + xs[2]))
text
0.1 + 0.2 == 0.3 : False
difference       : 5.551115123125783e-17
isclose          : True
float64 eps: 2.220446049250313e-16
float32 eps: 1.1920929e-07
relative error of the difference: 0.11022302462515646
left to right: 0.0  regrouped: 1.0

The last line is the whole lesson in two numbers. The same three values, added in two orders, give 00 and 11. Neither is a bug; both are what the arithmetic specifies. And the relative error line says that subtracting two numbers agreeing to 15 digits leaves an answer wrong by 11%.

κ(A)=σmaxσmin\kappa(\mathbf{A}) = \frac{\sigma_{\max}}{\sigma_{\min}}

the ratio of largest to smallest singular value. Interpretation, and it is a practical one: solving Ax=b\mathbf{A}\mathbf{x}=\mathbf{b} can lose about log10κ\log_{10}\kappa decimal digits of accuracy. With κ=1010\kappa = 10^{10} and float64’s 16 digits, expect around 6 good digits left.

κ\kappa is the same number that sets how slowly gradient descent converges on a quadratic (§7.1) and how unstable a least-squares fit is (Ch 9). One quantity, three chapters.

Solve

[2113][x1x2]=[510]\begin{bmatrix}2 & 1\\ 1 & 3\end{bmatrix}\begin{bmatrix}x_1\\ x_2\end{bmatrix} = \begin{bmatrix}5\\ 10\end{bmatrix}

By elimination: row 2 minus 12\tfrac12 row 1 gives 2.5x2=7.52.5x_2 = 7.5, so x2=3x_2 = 3. Back-substituting, 2x1+3=52x_1 + 3 = 5, so x1=1x_1 = 1. Solution (1,3)(1, 3).

The determinant is 2311=52\cdot3 - 1\cdot1 = 5, so the matrix is invertible and the solution is unique. Its eigenvalues solve λ25λ+5=0\lambda^2 - 5\lambda + 5 = 0:

λ=5±25202=5±52\lambda = \frac{5 \pm \sqrt{25 - 20}}{2} = \frac{5 \pm \sqrt5}{2}

so λ1=3.6180340\lambda_1 = 3.6180340 and λ2=1.3819660\lambda_2 = 1.3819660. Both positive, so the matrix is positive definite — and because it is symmetric, its condition number is the eigenvalue ratio 3.6180340/1.3819660=2.61803403.6180340 / 1.3819660 = 2.6180340. Very well conditioned; expect no accuracy loss at all.

Every one of those numbers is checked below.

verify_by_hand.py
import numpy as np
 
A = np.array([[2.0, 1.0],
              [1.0, 3.0]])
b = np.array([5.0, 10.0])
 
x = np.linalg.solve(A, b)
print("solution      :", x)
print("residual A@x-b:", A @ x - b)
print("determinant   :", np.linalg.det(A))
print("eigenvalues   :", np.linalg.eigvalsh(A))
print("condition     :", np.linalg.cond(A))
print("rank          :", np.linalg.matrix_rank(A))
print("trace         :", np.trace(A), "== sum of eigenvalues:",
      np.linalg.eigvalsh(A).sum())
print("det == product of eigenvalues:",
      np.isclose(np.linalg.det(A), np.prod(np.linalg.eigvalsh(A))))
 
# solve versus inv, on a badly conditioned matrix.
n = 8
H = np.array([[1.0 / (i + j + 1) for j in range(n)] for i in range(n)])  # Hilbert
x_true = np.ones(n)
rhs = H @ x_true
via_solve = np.linalg.solve(H, rhs)
via_inv   = np.linalg.inv(H) @ rhs
print("\nHilbert condition number: %.3e" % np.linalg.cond(H))
print("digits expected to survive: %.1f" % (16 - np.log10(np.linalg.cond(H))))
print("solve  max error: %.3e" % np.abs(via_solve - x_true).max())
print("inv    max error: %.3e" % np.abs(via_inv   - x_true).max())
text
solution      : [1. 3.]
residual A@x-b: [0. 0.]
determinant   : 5.000000000000001
eigenvalues   : [1.38196601 3.61803399]
condition     : 2.6180339887498953
rank          : 2
trace         : 5.0 == sum of eigenvalues: 5.0
det == product of eigenvalues: True
 
Hilbert condition number: 1.526e+10
digits expected to survive: 5.8
solve  max error: 1.309e-07
inv    max error: 9.537e-07

Three things to read off. The eigenvalues match the hand calculation to every printed digit. The determinant prints 5.000000000000001 rather than 5.0 — which is fact 2 above, and the reason np.isclose exists. And on the Hilbert matrix, solve beats inv by roughly a factor of two while both lose about ten digits, exactly as log10κ10.2\log_{10}\kappa \approx 10.2 predicted.

figure Accuracy against conditioning: solve versus explicit inverse matplotlib
Log-log plot of relative solution error against condition number for matrices with prescribed conditioning. Both the solve and the explicit-inverse curves rise roughly in proportion to the condition number, with the inverse curve consistently above the solve curve. Log-log plot of relative solution error against condition number for matrices with prescribed conditioning. Both the solve and the explicit-inverse curves rise roughly in proportion to the condition number, with the inverse curve consistently above the solve curve.
Error grows in step with the condition number for both methods, and the explicit inverse is worse at 26 of the 27 levels sampled. The dashed line is machine epsilon times kappa, the bound the conditioning imposes.
figure What precision actually buys you matplotlib
Two panels. Left: the accumulated error of summing one million values in float32 and in float64, with the float32 error orders of magnitude larger. Right: a bar chart of the number of significant decimal digits available in float16, float32 and float64. Two panels. Left: the accumulated error of summing one million values in float32 and in float64, with the float32 error orders of magnitude larger. Right: a bar chart of the number of significant decimal digits available in float16, float32 and float64.
float32 carries about seven decimal digits and float64 about sixteen. Summing a million numbers in float32 loses a visible fraction of the total.

The dashed line in the first figure is εκ(A)\varepsilon\,\kappa(\mathbf{A}) — machine epsilon times the condition number. It is the standard upper bound on the relative error: the conditioning of the problem amplifies the rounding error already present in the inputs, and that product says how much amplification to expect. Both methods track the line a small constant below it, which is what a well-implemented solver looks like.

That reframes what the plot is showing. The line’s slope belongs to the problem and no algorithm can change it; the vertical gap between the two curves is the only part that is the method’s fault, and inv loses that comparison at 26 of the 27 conditioning levels plotted.

So when a linear system gives a bad answer, the first question is not “is my solver good enough” but “what is κ\kappa”. At κ=1013\kappa = 10^{13} the bound is already 2×1032\times10^{-3} — three digits left of sixteen — and no choice of solver recovers them. The problem needs reformulating, which is what regularisation does (§9.2).

mathematicsNumPynot this
xy\mathbf{x}^\top\mathbf{y}x @ yx * y — that is elementwise
xy\mathbf{x}\mathbf{y}^\topnp.outer(x, y)x @ y — that contracts instead
AB\mathbf{A}\mathbf{B}A @ BA * B
A1b\mathbf{A}^{-1}\mathbf{b}np.linalg.solve(A, b)np.linalg.inv(A) @ b
A\mathbf{A}^\topA.T.T on a 1-D array does nothing
iaij\sum_i a_{ij}A.sum(axis=0)axis=1 eliminates the other index
x2\lVert\mathbf{x}\rVert_2np.linalg.norm(x)sum(x**2) — forgets the square root
x1\lVert\mathbf{x}\rVert_1np.linalg.norm(x, ord=1)the default ord is 2
eigenvalues, symmetric A\mathbf{A}np.linalg.eigvalsh(A)eigvals returns a complex dtype
a=ba = bnp.isclose(a, b)a == b
pch.quizTag Check yourself
  1. You want the dot product of two length-three arrays but you write the asterisk operator instead of the at operator. What happens?

    pch.quizShowAnswer

    B — It returns a length-three array of elementwise products, with no error — Nothing fails. You get an array of the right length holding the wrong quantity, which is why this bug survives review and surfaces later as a model that will not train.

  2. Why is solve preferred over computing an explicit inverse and multiplying?

    pch.quizShowAnswer

    B — It is both faster and more accurate, and the accuracy gap widens as the matrix becomes ill conditioned — solve factorises once and substitutes, while inv computes an entire matrix you then discard, accumulating error in every column. On a Hilbert matrix the explicit inverse is about twice as wrong.

  3. A matrix has condition number ten to the tenth. Roughly how many correct decimal digits should you expect from solving a system with it in double precision?

    pch.quizShowAnswer

    C — About six — Double precision carries about sixteen digits and the conditioning costs you the log base ten of kappa, which is ten. Six remain, and that estimate is a bound no solver can improve on — it is a property of the problem, not the algorithm.

  4. Subtracting a shape (3,1) array from a shape (1,3) array gives what?

    pch.quizShowAnswer

    C — A shape (3,3) matrix of pairwise differences — Broadcasting stretches each size-one dimension to match the other, producing the pairwise-difference matrix. Useful when intended, a silent bug when not — which is what keepdims and explicit reshape are for.

Exercise 1 – Dot product versus elementwise

Section titled “Exercise 1 – Dot product versus elementwise”

Exercise 3 – Never compare floats with equals

Section titled “Exercise 3 – Never compare floats with equals”
  • The shape is the mathematics — most NumPy bugs are shape bugs, and (n,) is not (n, 1).
  • @ contracts and * does not. Both run without error, which is why confusing them is the most expensive typo in numerical Python.
  • Broadcasting pads shapes on the left, then stretches any dimension of size one — so a column against a row gives a matrix, silently.
  • axis names the index you eliminate, not a direction; axis=0 leaves one value per column.
  • keepdims=True prevents half of all broadcasting accidents by keeping the reduced axis at size one.
  • Never invert a matrix to solve a systemsolve is faster and more accurate, and the gap widens with conditioning.
  • Use eigh for symmetric matrices: it is faster and returns real eigenvalues by construction.
  • == is the wrong comparison for floats because 0.1 plus 0.2 is not 0.3; use np.isclose.
  • Floating-point addition is not associative — the same three numbers summed in two orders can give 0 and 1.
  • The condition number predicts digit loss: solving costs you about log-base-ten of kappa decimal digits, and that bound is a property of the problem, not the solver.
  • matrix_rank is a numerical question with a tolerance, because exact zeros do not survive floating-point arithmetic.

Next: you have the prerequisites. Take the self-test on the Getting Ready overview to see what you can skip, or go straight to Linear Algebra.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading