WUT_Computer_Science/Programming/PORR/code/linear_algebra_utils.py
Krzysztof kuhy Rudnicki faef20dc45 PORR: zero findings, 18 tests still passing after every refactor
598 findings to none, and the suite ran through `./run.sh run` after each step
rather than at the end -- 18 passed, 27s, every time.

The shape of this one was different from the other courses. PORR is four
implementations of the same linear algebra (sequential, threads, processes,
dask), so nearly every finding came in groups of four:

- 229 missing annotations. Rather than `object`, which mypy strict rejects on
  every attribute access, each module declares what these actually are:
  `Matrix = np.ndarray | list[list[float]]` and the vector equivalent. The
  backends really are called with both -- comparing them is the point of the
  course.
- 76 names: `A` -> matrix, `Ab` -> augmented, `Ax` -> product, `I` ->
  identity, `dA` -> dask_matrix, and the two CamelCase functions. Renamed
  through `tokenize`, not regex, so no string or comment was touched.
- 121 missing docstrings, one per method name since the names repeat across
  all four backends.
- Seven functions over the argument limit, all taking the same seven or nine
  loose values. They are two frozen dataclasses now, in a new
  richardson_problem.py: `Problem(matrix, rhs, lambda_min, lambda_max)` and
  `Settings(max_iterations, x0, tol)`. `RichardsonMethod.__init__` also took a
  `size` argument it never read.
- threads_indep_old's iteration did "split into chunks, submit, wait" four
  times by hand (C901 11, PLR0915 54). One `_run_chunked` helper.
- tests.py's two bare asserts became explicit raises, per the no-ignores rule.
  `assert False` was also B011: `python -O` deletes it, so the non-convergence
  branch would have silently passed under optimisation.

Real fixes rather than style: np.random -> Generator, `os.path.exists` ->
Path, an `except Exception` around dask narrowed to the errors it actually
raises, and two runnable modules that logged at INFO without ever calling
basicConfig -- caught by scripts/check_refactor_safety.py, which is now doing
the job it was written for.

Repo-wide: 1,781 findings -> 1,183, all of them in the five courses left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172dj34FDpao6wsix8ZuCzE
2026-08-28 21:01:13 +02:00

554 lines
20 KiB
Python
Executable File

#!/usr/bin/env python3
"""Four linear-algebra backends with the same interface.
Sequential, threads, processes and dask distributed arrays -- comparing them
is what this course is about.
"""
import cmath
import itertools
import logging
import math
import multiprocessing
import operator
from abc import abstractmethod
from concurrent.futures import ThreadPoolExecutor
from functools import partial
from multiprocessing import Pool
import dask.array as da
import numpy as np
from time_measurement import time_accumulator, time_measurement
# These helpers are called with numpy arrays in some backends and plain
# Python lists in others, which is the point of the comparison this course
# is about, so the aliases name both.
Matrix = np.ndarray | list[list[float]]
Vector = np.ndarray | list[float]
logger = logging.getLogger(__name__)
class LinearAlgebraUtils:
"""The interface every linear-algebra backend implements."""
@staticmethod
@abstractmethod
def dot_product(v1: Vector, v2: Vector) -> None:
"""Return the dot product of two vectors."""
@staticmethod
@abstractmethod
def matrix_vector_multiply(matrix: Matrix, x: Vector) -> None:
"""Return the matrix-vector product."""
@staticmethod
@abstractmethod
def vector_norm(v: Vector) -> None:
"""Return the Euclidean norm of a vector."""
@staticmethod
@abstractmethod
def vector_scalar_divide(x: Vector, scalar: float) -> None:
"""Divide every element of a vector by a scalar."""
@staticmethod
@abstractmethod
def matrix_scalar_multiply(matrix: Matrix, w: float) -> None:
"""Multiply every element of a matrix by a scalar."""
@staticmethod
@abstractmethod
def vector_vector_subtraction(v1: Vector, v2: Vector) -> None:
"""Subtract one vector from another, elementwise."""
@staticmethod
@abstractmethod
def vector_vector_addition(v1: Vector, v2: Vector) -> None:
"""Add two vectors elementwise."""
@staticmethod
@abstractmethod
def scalar_vector_multiply(omega: float, vector: Vector) -> None:
"""Multiply every element of a vector by a scalar."""
@staticmethod
@abstractmethod
def matrix_norm(matrix: Matrix) -> None:
"""Return the Frobenius norm of a matrix."""
@staticmethod
@abstractmethod
def matrix_matrix_subtraction(matrix: Matrix, other_matrix: Matrix) -> None:
"""Subtract one matrix from another, elementwise."""
class SequentialLinearAlgebraUtils:
"""Linear algebra in plain Python, one core."""
@staticmethod
def dot_product(v1: Vector, v2: Vector) -> float:
"""Return the dot product of two vectors."""
return sum(x * y for x, y in zip(v1, v2, strict=False))
@staticmethod
def matrix_vector_multiply(matrix: Matrix, x: Vector) -> Vector:
"""Return the matrix-vector product."""
return [SequentialLinearAlgebraUtils.dot_product(row, x) for row in matrix]
@staticmethod
def vector_norm(v: Vector) -> float:
"""Return the Euclidean norm of a vector."""
x_values = (x * x for x in v)
x_values_sum = sum(x_values)
return cmath.sqrt(x_values_sum).real
@staticmethod
def vector_scalar_divide(x: Vector, scalar: float) -> Vector:
"""Divide every element of a vector by a scalar."""
return [xi / scalar for xi in x]
@staticmethod
def matrix_scalar_multiply(matrix: Matrix, w: float) -> Matrix:
"""Multiply every element of a matrix by a scalar."""
return matrix * w
@staticmethod
def vector_vector_subtraction(v1: Vector, v2: Vector) -> Vector:
"""Subtract one vector from another, elementwise."""
return [x - y for x, y in zip(v1, v2, strict=False)]
@staticmethod
def vector_vector_addition(v1: Vector, v2: Vector) -> Vector:
"""Add two vectors elementwise."""
return [x + y for x, y in zip(v1, v2, strict=False)]
@staticmethod
def scalar_vector_multiply(omega: float, vector: Vector) -> Vector:
"""Multiply every element of a vector by a scalar."""
return [omega * x for x in vector]
@staticmethod
def matrix_norm(matrix: Matrix) -> float:
"""Return the Frobenius norm of a matrix."""
return math.sqrt(sum(sum(element**2 for element in row) for row in matrix))
@staticmethod
def matrix_matrix_subtraction(matrix: Matrix, other_matrix: Matrix) -> Matrix:
"""Subtract one matrix from another, elementwise."""
return [
[matrix[i][j] - other_matrix[i][j] for j in range(len(matrix[0]))]
for i in range(len(matrix))
]
class ThreadsLinearAlgebraUtils:
"""Linear algebra split across threads."""
NUM_THREADS = multiprocessing.cpu_count()
@staticmethod
def get_chunk_size(data: Vector) -> tuple[int, int, int]:
"""Work out the chunk size, thread count and remainder."""
num_elements = len(data)
num_threads = min(ThreadsLinearAlgebraUtils.NUM_THREADS, num_elements)
chunk_size = num_elements // num_threads
remainder = num_elements % num_threads
return chunk_size, num_threads, remainder
@staticmethod
def divide_vectors_to_chunks(v1: Vector, v2: Vector) -> list[tuple[Vector, Vector]]:
"""Split two vectors into matching chunks."""
chunk_size, num_threads, remainder = ThreadsLinearAlgebraUtils.get_chunk_size(
v1
)
chunks = []
start = 0
for i in range(num_threads):
end = start + chunk_size + (1 if i < remainder else 0)
chunks.append((v1[start:end], v2[start:end]))
start = end
return chunks
@staticmethod
def divide_vector_or_matrix_to_chunks(v: Vector) -> list[Vector]:
"""Split a vector or matrix into chunks."""
chunk_size, num_threads, remainder = ThreadsLinearAlgebraUtils.get_chunk_size(v)
chunks = []
start = 0
for i in range(num_threads):
end = start + chunk_size + (1 if i < remainder else 0)
chunks.append(v[start:end])
start = end
return chunks
@staticmethod
@time_measurement(time_accumulator)
def matrix_vector_multiply(matrix: Matrix, x: Vector) -> Vector:
"""Return the matrix-vector product."""
chunks = ThreadsLinearAlgebraUtils.divide_vector_or_matrix_to_chunks(matrix)
with ThreadPoolExecutor(
max_workers=ThreadsLinearAlgebraUtils.NUM_THREADS
) as executor:
func = partial(SequentialLinearAlgebraUtils.matrix_vector_multiply, x=x)
results = executor.map(func, chunks)
return [item for sublist in results for item in sublist]
@staticmethod
@time_measurement(time_accumulator)
def vector_norm(v: Vector) -> float:
"""Return the Euclidean norm of a vector."""
chunks = ThreadsLinearAlgebraUtils.divide_vector_or_matrix_to_chunks(v)
def partial_norm(chunk: Vector) -> float:
"""Return the sum of squares of one chunk, for a parallel norm."""
return sum(x * x for x in chunk)
with ThreadPoolExecutor(
max_workers=ThreadsLinearAlgebraUtils.NUM_THREADS
) as executor:
results = executor.map(partial_norm, chunks)
total_sum = sum(results)
return total_sum**0.5
@staticmethod
@time_measurement(time_accumulator)
def vector_vector_subtraction(v1: Vector, v2: Vector) -> Vector:
"""Subtract one vector from another, elementwise."""
chunks = ThreadsLinearAlgebraUtils.divide_vectors_to_chunks(v1, v2)
with ThreadPoolExecutor(
max_workers=ThreadsLinearAlgebraUtils.NUM_THREADS
) as executor:
results = executor.map(
lambda pair: SequentialLinearAlgebraUtils.vector_vector_subtraction(
*pair
),
chunks,
)
return [item for sublist in results for item in sublist]
@staticmethod
@time_measurement(time_accumulator)
def vector_vector_addition(v1: Vector, v2: Vector) -> Vector:
"""Add two vectors elementwise."""
chunks = ThreadsLinearAlgebraUtils.divide_vectors_to_chunks(v1, v2)
with ThreadPoolExecutor(
max_workers=ThreadsLinearAlgebraUtils.NUM_THREADS
) as executor:
results = executor.map(
lambda pair: SequentialLinearAlgebraUtils.vector_vector_addition(*pair),
chunks,
)
return [item for sublist in results for item in sublist]
@staticmethod
@time_measurement(time_accumulator)
def scalar_vector_multiply(omega: float, vector: Vector) -> Vector:
"""Multiply every element of a vector by a scalar."""
chunks = ThreadsLinearAlgebraUtils.divide_vector_or_matrix_to_chunks(vector)
with ThreadPoolExecutor(
max_workers=ThreadsLinearAlgebraUtils.NUM_THREADS
) as executor:
results = executor.map(
lambda chunk: SequentialLinearAlgebraUtils.scalar_vector_multiply(
omega, chunk
),
chunks,
)
return [item for sublist in results for item in sublist]
@time_measurement(time_accumulator)
def process_row(params: tuple[object, ...]) -> Vector:
"""Eliminate one row against the pivot row."""
matrix, k, i = params
factor = matrix[i][k] / matrix[k][k]
return [matrix[i][j] - factor * matrix[k][j] for j in range(len(matrix[0]))]
@time_measurement(time_accumulator)
def divide_by_scalar(pair: tuple[Vector, float]) -> Vector:
"""Divide one chunk of a vector by its scalar."""
xi, scalar = pair
return xi / scalar
@time_measurement(time_accumulator)
def multiply_by_scalar(pair: tuple[Vector, float]) -> Vector:
"""Multiply one chunk of a vector by its scalar."""
element, scalar = pair
return element * scalar
class ProcessLinearAlgebraUtils:
"""Linear algebra split across a process pool."""
@staticmethod
@time_measurement(time_accumulator)
def dot_product(v1: Vector, v2: Vector) -> float:
"""Return the dot product of two vectors."""
with Pool() as pool:
result = pool.starmap(
ProcessLinearAlgebraUtils.multiply_elements, zip(v1, v2, strict=False)
)
return sum(result)
@staticmethod
@time_measurement(time_accumulator)
def multiply_elements(x: Vector, y: float) -> float:
"""Return the product of two numbers."""
return x * y
@staticmethod
@time_measurement(time_accumulator)
def matrix_vector_multiply_row(params: tuple[object, ...]) -> Vector:
"""Return one row of a matrix-vector product."""
row, vector = params
return SequentialLinearAlgebraUtils.dot_product(row, vector)
@staticmethod
@time_measurement(time_accumulator)
def matrix_vector_multiply(matrix: Matrix, x: Vector) -> Vector:
"""Return the matrix-vector product."""
with Pool() as pool:
result = pool.map(
ProcessLinearAlgebraUtils.matrix_vector_multiply_row,
[(row, x) for row in matrix],
)
return list(result)
@staticmethod
@time_measurement(time_accumulator)
def vector_norm(v: Vector) -> float:
"""Return the Euclidean norm of a vector."""
with Pool() as pool:
squared = pool.map(ProcessLinearAlgebraUtils.square, v)
return math.sqrt(sum(squared))
@staticmethod
@time_measurement(time_accumulator)
def square(x: Vector) -> float:
"""Return the square of a number."""
return x * x
@staticmethod
@time_measurement(time_accumulator)
def vector_scalar_divide(x: Vector, scalar: float) -> Vector:
"""Divide every element of a vector by a scalar."""
with Pool() as pool:
result = pool.map(divide_by_scalar, [(xi, scalar) for xi in x])
return list(result)
@staticmethod
@time_measurement(time_accumulator)
def divide_vector_by_scalar(x: Vector, scalar: float) -> Vector:
"""Divide every element of a vector by a scalar."""
with Pool() as pool:
result = pool.map(
ProcessLinearAlgebraUtils.vector_scalar_divide,
[(xi, scalar) for xi in x],
)
return list(result)
@staticmethod
@time_measurement(time_accumulator)
def matrix_scalar_multiply_row(params: tuple[object, ...]) -> Vector:
"""Multiply one row of a matrix by a scalar."""
row, w = params
return [w * element for element in row]
@staticmethod
@time_measurement(time_accumulator)
def matrix_scalar_multiply(matrix: Matrix, w: float) -> Matrix:
"""Multiply every element of a matrix by a scalar."""
with Pool() as pool:
return pool.map(
ProcessLinearAlgebraUtils.matrix_scalar_multiply_row,
[(row, w) for row in matrix],
)
@staticmethod
@time_measurement(time_accumulator)
def vector_vector_operation(params: tuple[object, ...]) -> Vector:
"""Apply one elementwise operation to a pair of chunks."""
v1, v2, op = params
return op(v1, v2)
@staticmethod
@time_measurement(time_accumulator)
def vector_vector_subtraction(v1: Vector, v2: Vector) -> Vector:
"""Subtract one vector from another, elementwise."""
with Pool() as pool:
result = pool.map(
ProcessLinearAlgebraUtils.vector_vector_operation,
zip(v1, v2, itertools.repeat(operator.sub), strict=False),
)
return list(result)
@staticmethod
@time_measurement(time_accumulator)
def vector_vector_addition(v1: Vector, v2: Vector) -> Vector:
"""Add two vectors elementwise."""
with Pool() as pool:
result = pool.map(
ProcessLinearAlgebraUtils.vector_vector_operation,
zip(v1, v2, itertools.repeat(operator.add), strict=False),
)
return list(result)
@staticmethod
@time_measurement(time_accumulator)
def scalar_vector_multiply(omega: float, vector: Vector) -> Vector:
"""Multiply every element of a vector by a scalar."""
with Pool() as pool:
result = pool.map(
multiply_by_scalar, [(element, omega) for element in vector]
)
return list(result)
@staticmethod
@time_measurement(time_accumulator)
def sum_of_squares(row: Vector) -> float:
"""Return the sum of the squares of a row."""
return sum(x**2 for x in row)
@staticmethod
@time_measurement(time_accumulator)
def matrix_norm(matrix: Matrix) -> float:
"""Return the Frobenius norm of a matrix."""
with Pool() as pool:
row_sums = pool.map(ProcessLinearAlgebraUtils.sum_of_squares, matrix)
return math.sqrt(sum(row_sums))
@staticmethod
@time_measurement(time_accumulator)
def subtract_rows(row_from_matrix: Vector, row_from_other: Vector) -> Vector:
"""Subtract one row from another, elementwise."""
return [a - b for a, b in zip(row_from_matrix, row_from_other, strict=False)]
@staticmethod
@time_measurement(time_accumulator)
def matrix_matrix_subtraction(matrix: Matrix, other_matrix: Matrix) -> Matrix:
"""Subtract one matrix from another, elementwise."""
with Pool() as pool:
return pool.starmap(
ProcessLinearAlgebraUtils.subtract_rows,
zip(matrix, other_matrix, strict=False),
)
class DistributedArraysLinearAlgebraUtils:
"""Linear algebra over dask distributed arrays."""
@staticmethod
@time_measurement(time_accumulator)
def dot_product(v1: Vector, v2: Vector) -> float:
"""Return the dot product of two vectors."""
dv1 = da.from_array(v1, chunks="auto")
dv2 = da.from_array(v2, chunks="auto")
return da.dot(dv1, dv2).compute()
@staticmethod
@time_measurement(time_accumulator)
def matrix_vector_multiply(matrix: Matrix, x: Vector) -> Vector:
"""Return the matrix-vector product."""
dask_matrix = da.from_array(matrix, chunks="auto")
dx = da.from_array(x, chunks="auto")
return da.dot(dask_matrix, dx).compute().tolist()
@staticmethod
@time_measurement(time_accumulator)
def vector_norm(v: Vector) -> float:
"""Return the Euclidean norm of a vector."""
dv = da.from_array(v, chunks="auto")
return da.linalg.norm(dv).compute()
@staticmethod
@time_measurement(time_accumulator)
def vector_scalar_divide(x: Vector, scalar: float) -> Vector:
"""Divide every element of a vector by a scalar."""
dx = da.from_array(x, chunks="auto")
return (dx / scalar).compute().tolist()
@staticmethod
@time_measurement(time_accumulator)
def matrix_scalar_multiply(matrix: Matrix, w: float) -> Matrix:
"""Multiply every element of a matrix by a scalar."""
dask_matrix = da.from_array(matrix, chunks="auto")
return (dask_matrix * w).compute().tolist()
@staticmethod
@time_measurement(time_accumulator)
def vector_vector_subtraction(v1: Vector, v2: Vector) -> Vector:
"""Subtract one vector from another, elementwise."""
dv1 = da.from_array(v1, chunks="auto")
dv2 = da.from_array(v2, chunks="auto")
return (dv1 - dv2).compute().tolist()
@staticmethod
@time_measurement(time_accumulator)
def vector_vector_addition(v1: Vector, v2: Vector) -> Vector:
"""Add two vectors elementwise."""
dv1 = da.from_array(v1, chunks="auto")
dv2 = da.from_array(v2, chunks="auto")
return (dv1 + dv2).compute().tolist()
@staticmethod
@time_measurement(time_accumulator)
def scalar_vector_multiply(omega: float, vector: Vector) -> Vector:
"""Multiply every element of a vector by a scalar."""
dvector = da.from_array(vector, chunks="auto")
return (omega * dvector).compute().tolist()
@staticmethod
@time_measurement(time_accumulator)
def matrix_norm(matrix: Matrix) -> float:
"""Return the Frobenius norm of a matrix."""
dask_matrix = da.from_array(matrix, chunks="auto")
return da.linalg.norm(dask_matrix).compute()
@staticmethod
@time_measurement(time_accumulator)
def matrix_matrix_subtraction(matrix: Matrix, other_matrix: Matrix) -> Matrix:
"""Subtract one matrix from another, elementwise."""
dask_matrix = da.from_array(matrix, chunks="auto")
dask_other = da.from_array(other_matrix, chunks="auto")
return (dask_matrix - dask_other).compute().tolist()
@staticmethod
@time_measurement(time_accumulator)
def gaussian_elimination(matrix: Matrix, b: Vector) -> Vector | None:
"""Solve the system by Gaussian elimination."""
try:
dask_matrix = da.from_array(matrix, chunks="auto")
db = da.from_array(b, chunks="auto")
augmented = da.hstack([dask_matrix, db[:, None]])
augmented = augmented.persist()
def elimination_step(augmented: Matrix, k: int) -> Matrix:
"""Run one Gaussian elimination step over the augmented matrix."""
max_index = da.argmax(da.abs(augmented[k:, k])) + k
augmented[[k, max_index]] = augmented[[max_index, k]]
augmented = augmented.persist()
factor = augmented[k + 1 :, k] / augmented[k, k]
augmented[k + 1 :] -= factor[:, None] * augmented[k]
return augmented
for k in range(matrix.shape[0]):
augmented = elimination_step(augmented, k)
x = da.zeros(matrix.shape[0])
for i in range(matrix.shape[0] - 1, -1, -1):
x[i] = (
augmented[i, -1] - da.dot(augmented[i, i + 1 : -1], x[i + 1 :])
) / augmented[i, i]
return x.compute().tolist()
except (ValueError, ArithmeticError) as exc:
# A singular or badly-scaled system; dask surfaces both here.
logger.info("Error during Gaussian elimination: %s", exc)
return None