5
\$\begingroup\$

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()
\$\endgroup\$

3 Answers 3

7
\$\begingroup\$

General Impressions

You have paid close attention to the PEP 8 recommendations. I love the comprehensive docstrings and type hints.

User Interface

Rather than having the user enter "exit" to terminate, more common and simpler for the user would be to enter "q" (or "Q") to "quit".

Error Handling

You displayed a 4 X 5 matrix where the correct input would require 5 comma-separated numbers. I entered only 4 numbers and the statement if (answer == result).all(): raised ValueError: operands could not be broadcast together with shapes (4,) (5,). Letting the code generate a ValueError exception does not result in a useful error message for the user. Later, when I needed to supply 3 comma-separated numbers in response to the prompt "R5+2R4" but instead entered "3,a,5", no error message was generated. Instead, the prompt "R3+2R4" was redisplayed.

I would suggest that prior to comparing the inputted answer with the expected result you validate the input to contain the correct number of comma-separated integers. You could pass to function _input_row_or_exit as an additional the number of integers expected and it would do the validation.

One possible rewrite of function _input_row_or_exit is:

import re
...

def _input_row_or_exit(n: int, prompt: object = "", /) -> NDArray[np.integer] | None:
    """
    Get a 1D array of integers from the user, unless they type 'q'. If the
    input is a 1D array of integers, the integers must be separated with
    commas.

    :param n: The number of comma-separated integers the user must input.
    :param prompt: The prompt for the user.
    :return: A 1D array if the user inputted one. Otherwise, None.
    """
    rex = re.compile(fr'([+-]?[0-9]+\s*,\s*){{{n-1}}}[+-]?[0-9]+')
    while True:
        raw_input = input(prompt).strip()
        if raw_input.lower() == 'q':
            return None
        if not rex.fullmatch(raw_input):
            print(f'You must enter {n} comma-separated integers.')
        else:
            return np.array([np.int_(x.strip()) for x in raw_input.split(",")])

Then in function main we change the user instructions slightly and pass the number of integers expected in the user's answer:

...
def main() -> None:
    print("Enter just the requested row. Separate entries with commas. " \
          "Spaces okay. 'q' to quit.\n")

    while True:
        operation = random_operation(-10, 11, 2, 6)
        print(_pretty_array(operation.matrix))

        answer = retry_call(
            _input_row_or_exit,
            (operation.matrix.shape[1], f"{operation.to_readable()}\n"),
            exceptions=ValueError
        )

        ... # The rest is unchanged
\$\endgroup\$
3
\$\begingroup\$

Overview

The code layout is good, and you used meaningful names for classes, functions and variables. The docstrings and type hints are terrific.

Portability

When I run the code, I see this error:

AttributeError: module 'numpy.dtypes' has no attribute 'StringDType'. Did you mean: 'StrDType'?

I think StringDType is only available with versions of numpy that are newer than what is installed on my system. StrDType seems to work for me.

UX

When I run the code, I see a number matrix, as expected. Then I see a cryptic line under the matrix, something like:

R1+8R2

Consider adding more details to the output to explain what the user is seeing.

\$\endgroup\$
3
\$\begingroup\$

If you're going to have global random state, then just use the module-level functions in np.random rather than making your own _rng instance. But also... don't use global random state. Move _rng into main() and pass it in where it's needed.

I don't think RowOperation presents the best use case for a Protocol. Instead, I recommend using abc for this case.

Rather than

rows[0, :] + rows[1, :] * self.factor

you can write

(1, self.factor) @ rows

or you could move one step back, replace self.rows with a vector containing a 0, 1 and self.factor in some order, and multiply that with self.matrix with no reindexing.

\$\endgroup\$
0

You must log in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.