I want to practice mentally computing matrix row operations for a linear algebra course I just started. As such, I'm developing a program that randomly generates matrices and row operations, then lets the user input answers to the console.
The program uses numpy for most matrix manipulation and supports row addition and row multiplication.
Here's the source:
"""
Tools for practicing the elementary row operations used in Gaussian
elimination.
When run as a script, generates practice questions using random matrices and
random operations until the user enters 'exit'. Matrices will have dimensions
MxN satisfying 2<=M<=5 and 2<=N<=5. Matrix entries and constant factors will
be integers between -10 and 10, inclusive.
"""
from dataclasses import dataclass
from typing import Protocol, Self
import numpy as np
from numpy.typing import NDArray
from retry.api import retry_call
_rng = np.random.default_rng()
class RowOperation(Protocol):
"""
A row operation on a given matrix.
:param matrix: The matrix for the row operation.
"""
matrix: NDArray[np.integer]
def compute(self) -> NDArray[np.integer]:
"""
The row that results from the row operation.
:return: The result of the row operation.
"""
...
def to_readable(self) -> str:
"""
The "name" of the row operation. A readable description of what it is.
Should include both an indication of the type of operation and all
rows or constant factors involved.
:return: A description of the row operation.
"""
...
@classmethod
def random_from_matrix(cls, matrix: NDArray[np.integer], min_value: int,
max_value: int) -> Self:
"""
Generate a random operation of this type from a given matrix.
:param matrix: The matrix on which the row operation should be
performed.
:param min_value: The minimum value for any generated constants,
inclusive.
:param max_value: The maximum value for any generated constants,
exclusive.
:return: A random row operation of this type.
"""
...
@dataclass
class RowAddition(RowOperation):
"""
The addition of one row of a matrix and a constant multiple of another.
:param matrix: The matrix for the row addition.
:param rows: The zero-based indices of the two rows.
:param factor: The constant factor by which the second row should be
multiplied.
"""
matrix: NDArray[np.integer]
rows: NDArray[np.integer]
factor: np.integer
def compute(self) -> NDArray[np.integer]:
"""
The result of the row addition.
:return: The result of the row addition.
"""
rows = self.matrix[self.rows, :]
return rows[0, :] + rows[1, :] * self.factor
def to_readable(self) -> str:
"""
The "name" of the row addition.
:return: "RX+kRY", where X and Y are the 1-indexed row numbers and k is
the constant factor.
"""
row_names = [f"R{n+1}" for n in self.rows]
signed_factor = (str(self.factor) if self.factor < 0
else f"+{self.factor}")
return signed_factor.join(row_names)
@classmethod
def random_from_matrix(cls, matrix: NDArray[np.integer],
min_value: int, max_value: int) -> Self:
"""
Generate a random row addition for a given matrix using a random
64-bit signed integer factor.
:param matrix: The matrix for the row addition.
:param min_value: The lower bound for the constant factor,
inclusive.
:param max_value: The upper bound for the constant factor,
exclusive.
:return: A random row addition.
"""
row_indices = _rng.choice(matrix.shape[0], 2, replace=False)
factor = _rng.integers(min_value, max_value, dtype=np.int_)
return cls(matrix, row_indices, factor)
@dataclass
class RowMultiplication(RowOperation):
"""
The multiplication of one row of a matrix by a constant.
:param matrix: The matrix for the row multiplication.
:param row: The zero-based index of the row to be multiplied.
:param factor: The constant factor by which the row should be multiplied.
"""
matrix: NDArray[np.integer]
row: np.integer
factor: np.integer
def compute(self) -> NDArray[np.integer]:
"""
The result of the row multiplication.
:return: The result of the row multiplication.
"""
return self.matrix[self.row, :] * self.factor
def to_readable(self) -> str:
"""
The "name" of the row multiplication.
:return: "kX", where X is the 1-indexed row number and k is the
constant factor.
"""
return f"{self.factor}R{self.row + 1}"
@classmethod
def random_from_matrix(cls, matrix: NDArray[np.integer], min_value: int,
max_value: int) -> Self:
"""
Generate a random row multiplication for a given matrix using a random
64-bit signed integer factor.
:param matrix: The matrix for the row multiplication.
:param min_value: The lower bound for the constant factor,
inclusive.
:param max_value: The upper bound for the constant factor,
exclusive.
:return: A random row multiplication.
"""
row_index = _rng.integers(0, matrix.shape[0], 1)[0]
factor = _rng.integers(min_value, max_value, dtype=np.int_)
return cls(matrix, row_index, factor)
def random_operation(min_value: int, max_value: int, min_rows: int,
max_rows: int) -> RowOperation:
"""
A random row operation on a random integer matrix using 64-bit signed
integer values.
:param min_value: The lower bound for each matrix entry or factor,
inclusive.
:param max_value: The upper bound for each matrix entry or factor,
exclusive.
:param min_rows: The minimum number of rows for each matrix intry,
inclusive. Must be at least 2.
:param max_rows: The maximum number of rows for each matrix entry,
exclusive.
:return: A RowOperation object representing the generated row operation.
"""
dimensions = _rng.integers(min_rows, max_rows, 2)
matrix = _rng.integers(min_value, max_value, dimensions, dtype=np.int_)
operation_type = _rng.choice(np.array((RowAddition, RowMultiplication)))
return operation_type.random_from_matrix(matrix, min_value, max_value)
def _pretty_array(array: NDArray) -> str:
"""
A table representation of a 2D or 1D array. Rows are separated with
newlines, entries with tabs. Works poorly with entries longer than the tab
length.
:param array: The array to be converted.
:return: A 2D table. Newlines separate rows; tabs separate entries.
"""
string_array = array.astype(np.dtypes.StringDType)
if len(array.shape) == 2:
return "\n".join("\t".join(row) for row in string_array)
else:
return "\t".join(string_array)
def _input_row_or_exit(prompt: object = "", /) -> NDArray[np.integer] | None:
"""
Get a 1D array of integers from the user, unless they type 'exit'. If the
input is a 1D array of integers, the integers must be separated with
commas. Throws a ValueError on inputs that are neither a valid 1D array nor
'exit'.
:param prompt: The prompt for the user.
:return: A 1D array if the user inputted one. Otherwise, None.
"""
raw_input = input(prompt)
if raw_input == "exit":
return None
return np.array([np.int_(x.strip()) for x in raw_input.split(",")])
def main() -> None:
print("Enter just the requested row. Separate entries with commas. " \
"Spaces okay. 'exit' to exit.\n")
while True:
operation = random_operation(-10, 11, 2, 6)
print(_pretty_array(operation.matrix))
answer = retry_call(_input_row_or_exit, (f"{operation.to_readable()}\n",),
exceptions=ValueError)
if answer is None:
break
result = operation.compute()
if (answer == result).all():
print("Correct\n")
else:
print(f"Incorrect:\n{_pretty_array(result)}\n")
if __name__ == "__main__":
main()