Basic Operations
Why Linear Algebra Is the Engine of Scientific Computing
Every major algorithm in scientific computing β from solving PDEs to training neural networks to decoding wireless signals β reduces to linear algebra at its core. NumPy and SciPy provide thin Python wrappers around battle-tested Fortran/C libraries (BLAS and LAPACK), giving you near-C performance with Python convenience.
In wireless communications (Book 1), the channel equation is the starting point for everything: detection, estimation, beamforming, and precoding are all linear algebra operations on .
Definition: Matrix Multiplication with the @ Operator
Matrix Multiplication with the @ Operator
For and , the product has entries:
In NumPy, this is computed via the @ operator (PEP 465):
C = A @ B # matrix-matrix
y = A @ x # matrix-vector
s = x.conj() @ y # inner product (scalar)
The @ operator dispatches to BLAS gemm (general matrix multiply),
which achieves near-peak hardware throughput via cache-optimal
blocking and SIMD vectorization.
For 1D arrays, @ computes the inner product. For 2D arrays, it
computes the matrix product. For higher dimensions, it broadcasts
over batch dimensions β a pattern used heavily in batched MIMO processing.
Definition: Transpose and Conjugate Transpose
Transpose and Conjugate Transpose
The transpose swaps rows and columns: .
The conjugate transpose (Hermitian transpose) additionally conjugates each element: .
A_T = A.T # transpose
A_H = A.conj().T # conjugate transpose
A_H = np.conj(A.T) # equivalent
For real matrices, . In wireless communications, channel matrices are complex, so appears in matched filtering, MMSE estimation, and beamforming.
Definition: Trace of a Matrix
Trace of a Matrix
The trace of a square matrix is:
Key properties:
- (cyclic property)
- (sum of eigenvalues)
t = np.trace(A)
In MIMO, gives the total transmit power when is the transmit covariance matrix.
Definition: Determinant
Determinant
The determinant of a square matrix is a scalar that encodes the signed volume scaling factor of the linear transformation:
d = np.linalg.det(A)
sign, logdet = np.linalg.slogdet(A) # numerically stable
For large matrices, use slogdet to avoid overflow/underflow:
it returns so that
.
In MIMO capacity calculations, the log-determinant appears directly: .
Definition: Matrix Inverse and Solving Linear Systems
Matrix Inverse and Solving Linear Systems
The inverse of a nonsingular matrix satisfies . To solve :
# BAD: computes full inverse (slow, numerically unstable)
x_bad = np.linalg.inv(A) @ b
# GOOD: solves directly via LU decomposition
x = np.linalg.solve(A, b)
np.linalg.solve calls LAPACK gesv, which performs LU factorization
with partial pivoting: .
Cost: flops for the factorization,
flops for the solve. Computing and applying
costs flops and doubles the numerical error.
Definition: Vector and Matrix Norms
Vector and Matrix Norms
The -norm of a vector is:
Common choices: (Euclidean), (Manhattan), (max).
The matrix -norm is the induced operator norm:
The Frobenius norm uses all entries: .
np.linalg.norm(x, ord=2) # vector 2-norm
np.linalg.norm(A, ord=2) # largest singular value
np.linalg.norm(A, ord='fro') # Frobenius norm
Theorem: Solve Is Faster and More Accurate Than Inverse
For a nonsingular and
, computing via
np.linalg.solve(A, b) requires flops
and produces a relative error bounded by
.
Computing via
np.linalg.inv(A) @ b requires flops and the
error bound has an additional factor of .
solve performs LU decomposition and forward/back substitution,
while inv computes all columns of even
though you only need one linear combination ().
Theorem: Transpose and Trace Identities
For conformable matrices , :
- (Frobenius inner product)
Proof of (1)
.
Proof of (3)
.
Theorem: LU Decomposition with Partial Pivoting
Any nonsingular can be factored as , where is a permutation matrix, is unit lower triangular, and is upper triangular. The factorization costs flops.
LU decomposition is Gaussian elimination expressed as a matrix product. Once computed, solving becomes two triangular solves (), so multiple right-hand sides can be solved cheaply.
Use scipy.linalg.lu(A) for the factorization.
scipy.linalg.lu_factor + lu_solve is more efficient when solving multiple systems.
Example: Solving the MIMO Channel Equation
In a MIMO system, the received signal is
. Given a known
channel and received vector , recover the
transmitted symbol vector using zero-forcing (ZF):
.
Show that solve gives the same answer as inv but is faster.
Set up the system
import numpy as np
np.random.seed(42)
N = 4
H = (np.random.randn(N, N) + 1j * np.random.randn(N, N)) / np.sqrt(2)
x_true = np.array([1+1j, -1+1j, 1-1j, -1-1j]) / np.sqrt(2) # QPSK
noise = 0.01 * (np.random.randn(N) + 1j * np.random.randn(N))
y = H @ x_true + noise
Solve with inv vs solve
import time
# Method 1: inv (BAD)
t0 = time.perf_counter()
x_inv = np.linalg.inv(H) @ y
t_inv = time.perf_counter() - t0
# Method 2: solve (GOOD)
t0 = time.perf_counter()
x_solve = np.linalg.solve(H, y)
t_solve = time.perf_counter() - t0
print(f"Max error (inv): {np.max(np.abs(x_inv - x_true)):.2e}")
print(f"Max error (solve): {np.max(np.abs(x_solve - x_true)):.2e}")
Interpret the result
Both methods recover the transmitted symbols up to noise.
For large , solve is faster and produces
smaller numerical residuals. The MMSE detector
should similarly use solve, not inv.
Example: MIMO Capacity via Log-Determinant
Compute the ergodic capacity of a MIMO Rayleigh
channel at SNR values from 0 to 30 dB. The capacity is:
Use slogdet for numerical stability and average over 1000 channel realizations.
Monte Carlo capacity computation
import numpy as np
Nr, Nt, n_trials = 4, 4, 1000
snr_db = np.arange(0, 31, 2)
capacity = np.zeros(len(snr_db))
for trial in range(n_trials):
H = (np.random.randn(Nr, Nt) + 1j * np.random.randn(Nr, Nt)) / np.sqrt(2)
HHH = H @ H.conj().T
for i, db in enumerate(snr_db):
snr_lin = 10 ** (db / 10)
M = np.eye(Nr) + (snr_lin / Nt) * HHH
sign, logdet = np.linalg.slogdet(M)
capacity[i] += logdet / np.log(2) # convert ln to log2
capacity /= n_trials
print("SNR (dB) | Capacity (bits/s/Hz)")
for db, cap in zip(snr_db, capacity):
print(f" {db:5.1f} | {cap:.2f}")
Why slogdet?
At high SNR, can exceed , causing
overflow with np.linalg.det. slogdet returns the
log of the absolute value directly, avoiding this issue.
Example: Batch Solving Multiple Systems
In OFDM, each subcarrier has its own channel coefficient.
Given 64 subcarriers with MIMO, solve all 64
systems simultaneously
using vectorized np.linalg.solve.
Batch solve
import numpy as np
n_sub = 64
Nr, Nt = 2, 2
np.random.seed(42)
# Shape: (64, 2, 2) β one channel matrix per subcarrier
H = (np.random.randn(n_sub, Nr, Nt)
+ 1j * np.random.randn(n_sub, Nr, Nt)) / np.sqrt(2)
x_true = (np.random.choice([-1, 1], (n_sub, Nt))
+ 1j * np.random.choice([-1, 1], (n_sub, Nt))) / np.sqrt(2)
# Vectorized matrix-vector product across batch dimension
y = np.einsum('bij,bj->bi', H, x_true)
# Batch solve: (64, 2, 2) \ (64, 2) -> (64, 2)
x_hat = np.linalg.solve(H, y)
print(f"Max recovery error: {np.max(np.abs(x_hat - x_true)):.2e}")
Solve vs Inv: Speed and Accuracy Comparison
Compare np.linalg.solve and np.linalg.inv @ b in terms of
execution time and relative error as a function of matrix size
and condition number.
Parameters
BLAS
Basic Linear Algebra Subprograms β a standardized API for vector and matrix operations (dot products, matrix multiply). OpenBLAS and Intel MKL are common high-performance implementations.
Related: LAPACK
LAPACK
Linear Algebra PACKage β a Fortran library providing routines for solving linear systems, eigenvalue problems, SVD, and matrix factorizations. NumPy and SciPy call LAPACK under the hood.
Related: BLAS
condition number
The ratio . A large condition number means the system is ill-conditioned: small perturbations in cause large changes in .
Common Mistake: Using inv Instead of solve
Mistake:
Computing x = np.linalg.inv(A) @ b to solve .
This is slower and can double the numerical error.
Correction:
Always use x = np.linalg.solve(A, b). If you need to solve
multiple right-hand sides, pass a matrix :
X = np.linalg.solve(A, B) solves all columns simultaneously.
Common Mistake: Using np.dot Instead of @
Mistake:
Using np.dot(A, B) for matrix multiplication. While it works,
it behaves differently for higher-dimensional arrays compared to @.
Correction:
Use the @ operator for matrix multiplication. np.dot treats
the last axis of the first argument and the second-to-last of the
second, which can be confusing for 3D+ arrays. @ follows the
standard matmul broadcasting rules.
Common Mistake: Determinant Overflow for Large Matrices
Mistake:
Using np.linalg.det(A) for large matrices, which can return inf
or 0.0 due to floating-point overflow/underflow.
Correction:
Use sign, logdet = np.linalg.slogdet(A) and work with
the log-determinant directly. This is essential for MIMO capacity
calculations and Gaussian log-likelihoods.
Why This Matters: Zero-Forcing Detection in MIMO
The zero-forcing (ZF) detector for a MIMO system is:
This is exactly a least-squares solve. In NumPy:
x_zf = np.linalg.solve(H.conj().T @ H, H.conj().T @ y)
Or equivalently via lstsq (Section 6.6). The matrix
is Hermitian positive definite when
has full column rank, so scipy.linalg.solve
with assume_a='pos' is even faster.
Historical Note: PEP 465: The @ Operator
Before Python 3.5, matrix multiplication required np.dot(A, B)
or A.dot(B), making chained products like
unreadable. Nathaniel Smith
proposed PEP 465 in 2014, introducing @ as a dedicated matrix
multiply operator. It was one of the most rapidly adopted PEPs
in scientific Python history.
Historical Note: LAPACK: 35+ Years of Linear Algebra
LAPACK was first released in 1992 as a successor to LINPACK and
EISPACK, written primarily by Jack Dongarra and collaborators.
It remains the gold standard for dense linear algebra and is
called by virtually every scientific computing language β NumPy,
MATLAB, R, Julia, and Mathematica all use LAPACK routines.
The naming convention (dgesv, zheev, etc.) encodes the
precision (d=double, z=complex double), matrix type (ge=general,
he=Hermitian), and operation (sv=solve, ev=eigenvalue).
Quick Check
What is the best way to solve in NumPy?
x = np.linalg.solve(A, b)
x = np.linalg.inv(A) @ b
x = A @ b
x = np.dot(np.linalg.inv(A), b)
x = np.linalg.solve(A, b)solve uses LU decomposition and is both faster and more numerically stable than computing the inverse.
Quick Check
Why should you use slogdet instead of det for large matrices?
To avoid overflow/underflow of the determinant
slogdet is faster than det
det does not work for complex matrices
slogdet returns eigenvalues as a bonus
For large matrices, the determinant can easily exceed floating-point range. slogdet returns log|det| which stays in range.
Key Takeaway
Never compute just to multiply. Use
np.linalg.solve(A, b) instead of np.linalg.inv(A) @ b.
Use np.linalg.slogdet instead of np.linalg.det for large matrices.
These are not style preferences β they are correctness requirements
for ill-conditioned systems.
Key Takeaway
The @ operator is your primary tool for matrix operations.
It handles matrix-matrix, matrix-vector, and batched products.
It dispatches to BLAS, which is typically the fastest code running
on your machine.
Basic Linear Algebra Operations
# Code from: ch06/python/basic_linalg.py
# Load from backend supplements endpoint