# Python - A Numeric Matrix Calculator/Processor

it's the second object oriented program i've worked on, more details on https://hyperskill.org/projects/96?goal=391.

I've added as much documentation as needed, as a first time, to explain each component.

class Matrix:

def __init__(self, dimension, elements=None):
"""
Use:
Matrix([num_of_rows, num_of_columns], [2D array or 1D array or None/blank])

self.dimension is a list with two values, refering to the number of rows and columns of the matrix, Ex: [3, 3] is a 3x3 matrix

self.matrix refers to the matrix in terms of 2D Arrays,
if elements is not given as an argument, then it will create a matrix of 0s with the dimensions provided
if elements is given as a certain list/matrix, it will store it as a matrix in it

self.transposition_type is a dictionary storing the possible transpositions functions of a matrix, if asked for

Input Types:
Matrix([3, 3]) or Matrix([3, 3], [])             : creates a 3x3 matrix with only 0s
Matrix([3, 3], [[1, 2, 3], [4, 5, 6], [7, 8, 9]]): creates a 3x3 matrix with a 2D array as elements
Matrix([3, 3], [1, 2, 3, 4, 5, 6, 7, 8, 9])      : creates a 3x3 matrix with a 1D array, elements are filled left to right, top to bottom in matrix

"""
self.dimension = dimension
self.transposition_type = {'main_diagonal': self.transposition_along_main_diagonal,
'side_diagonal': self.transposition_along_side_diagonal,
'horizontal'   : self.transposition_along_horizontal   ,
'vertical'     : self.transposition_along_vertical     }
self.matrix = self.default_matrix() if elements == None else self.check_elements(elements)

def __add__(self, other):
"""
Add two matrices

Matrix + Matrix

Rules:
1) Matrixes should have the same dimensions
2) Matrixes cannot be added by other data types
"""
assert type(other) == Matrix, f"Matrix cannot be added by {type(other)}"
assert self.dimension == other.dimension, "Dimensions should be same"
return Matrix(self.dimension, [ [self.matrix[row][column] + other.matrix[row][column] for column in range(self.dimension[1])] for row in range(self.dimension[0])])

def __sub__(self, other):
"""
Subtract two matrices

Matrix - Matrix

Rules:
1) Matrixes should have the same dimensions
2) Matrixes cannot be added by other data types
"""
assert type(other) == Matrix, f"Matrix cannot be subtracted by {type(other)}"
assert self.dimension == other.dimension, "Dimensions should be same"
return Matrix(self.dimension, [ [self.matrix[row][column] - other.matrix[row][column] for column in range(self.dimension[1])] for row in range(self.dimension[0])])

def __mul__(self, other):
"""
multiples a matrix with an integer/float or another matrix

Multiplication process is different for a constant and another matrixes, so they go to different processes, depending on the type of other

Matrix * constant | constant * Matrix
Matrix * Matrix
"""
if isinstance(other, (int, float)): # int/float * matrix
return self.constant_multiplication(other)
elif isinstance(other, Matrix):
return self.matrix_multiplication(other)
raise Exception(f"Matrixes cannot be multiplied by {type(other)}")

def __rmul__(self, other):
"""
Does the same thing as the __mul__ method, just instead of matrix * number, it also supports number * matrix
"""
return self.__mul__(other)

def __str__(self):
"""
Displays Matrixes in a format
Ex:

Matrix( [3, 3], [1, 2, 3, 4, 5, 6, 7, 8, 9]) or Matrix( [3, 3], [[1, 2, 3], [4, 5, 6], [7, 8, 9]])

1 2 3
4 5 6
7 8 9
"""
return "\n".join(" ".join(str(value) for value in row) for row in self.matrix)

def check_elements(self, elements):
"""
Checks if the elements provided on instantiation is a 2D array or a 1D array, or None of them

if its a 1D array, it calls the method self.set_matrix_elements_by_array(elements)

if its a 2D array, it can be directly set to the self.matrix attribute directly

if its an empty list [], then it will make a default matrix with 0s only
"""
if elements:
if all(map(lambda x: type(x) == list, elements)):
if all(map(lambda x: type(x) == float or type(x) == int, elements[0])):
return elements
elif all(map(lambda x: type(x) == float or type(x) == int, elements)):
return self.set_matrix_elements_by_array(elements)
raise Exception('Invalid Input Type')
else:
return self.default_matrix()

def default_matrix(self, dimensions=None):
"""
Makes a 0 element only matrix with the dimensions provided

Dimensions cant be (0, 0)
"""
dimension = self.dimension if dimensions == None else dimensions
assert dimension[0] != 0 and dimension[1] != 0, "Dimensions cannot be (0, 0)"
return [dimension[1] * [0] for _ in range(dimension[0])]

def set_matrix_elements_by_array(self, elements):
"""
creates and returns a matrix (2D array) using a 1D array, given dimensions

where the number of elements in the array must be equal to the product of the number of rows and columns
"""
assert len(elements) == (self.dimension[0] * self.dimension[1]), "Number of elements is not equal"
j = 0
matrix = []
for i in range(self.dimension[1], self.dimension[1]*self.dimension[0]+1, self.dimension[1]):
matrix.append(elements[j:i])
j = i
return matrix

def constant_multiplication(self, constant, matrix=None):
"""
multiples a matrix with a constant and returns a new matrix

"""
matrix = self.matrix if matrix is None else matrix
return Matrix(self.dimension, [ [round(matrix[row][column] * constant, 2) for column in range(self.dimension[1])] for row in range(self.dimension[0])])

def matrix_multiplication(self, other):
"""
multiples a matrix with another matrix and returns a new matrix

creates a 0 element only 2D array with the appropriate dimensions, depending on the two matrixes multiplied

then changes the 2D array in place, and creates and returns a matrix using that 2D array
"""
assert self.dimension[1] == other.dimension[0], "The number of columns of the first matrix must equal the number of rows of the second matrix"
matrix_array = self.default_matrix([self.dimension[0], other.dimension[1]])
for i in range(self.dimension[0]):
for j in range(other.dimension[1]):
for k in range(other.dimension[0]):
matrix_array[i][j] += self.matrix[i][k] * other.matrix[k][j]
return Matrix([self.dimension[0], other.dimension[1]], matrix_array)

def transposition_along_main_diagonal(self, matrix=None):
"""
performs transposition along the main diagonal from left to right
- just switch the position of row and columns for each elements: element[row][column] = element[column][row]
Ex:
the diagonal is represented by 1 5 6
1 1 1        1 2 3
2 2 2  --->  1 2 3
3 3 3        1 2 3
"""
matrix = self.matrix if matrix is None else matrix
return list(map(list, zip(*matrix)))

def transposition_along_side_diagonal(self):
"""
performs transposition along the side diagonal from right to left
- just perform a transposition along the main diagonal, then reverse the position of each row, and then reverse the elements in each row
Ex:
the diagonal is represented by -1 2 3
1 1 -1  transpos    1  2  3    reverse_pos  -1 -2 -3  reverse_rows   -3 -2 -1
2 2 -2  --------->  1  2  3    --------->    1  2  3  ------------>   3  2  1
3 3 -3             -1 -2 -3                  1  2  3                  3  2  1

"""
return [row[::-1] for row in self.transposition_along_main_diagonal()[::-1]]

def transposition_along_horizontal(self):
"""
performs transposition along the horizontal
- just simply reverse the position of rows
Ex:
the horizontal is represented by 4 5 6
1 2 3  reverse   7 8 9
4 5 6 -------->  4 5 6
7 8 9            1 2 3
"""
return [row for row in self.matrix[::-1]]

def transposition_along_vertical(self):
"""
performs transposition along the vertical
- just simply reverse the elements of each row
Ex:
the vertical is represented by 2 5 8
1 2 3 rev elems   3 2 1
4 5 6 --------->  6 5 4
7 8 9             9 8 7
"""
return [row[::-1] for row in self.matrix]

def matrix_transposition(self, choice):
"""
returns the transposition of a matrix as a Matrix object, using switch case like dictionaries, with the dimensions swapped
"""
return Matrix([self.dimension[1], self.dimension[0]], self.transposition_type[choice]())

def get_minor(self, matrix, i, j):
"""
acquires the minor/submatrix of a matrix, with dimensions (n-1, n-1), with n being the current dimensions of the matrix, based on the ith row and jth column given, which is submatrix formed by all the other elements that dont have the row i and column j
Ex:
the minor of element (5) at i=1 j=1, cancels 2 and 8, because they are in the jth column and cancels 4 and 6 because they are in the ith row
| 1 2 3 |      | 1  3 |
| 4 5 6 | ---> | 7  9 |
| 7 8 9 |
"""
return [row[:j] + row[j+1:] for row in (matrix[:i]+matrix[i+1:])]

def determinant_helper(self, matrix):
"""
recursively finds the determinant of a matrix, given as an argument, by finding the minor of every matrix using cofactors till it reaches the base cases

basecase 1: matrix 1x1, determinant is the element left in the matrix
Ex:
| 17 | has a determinant of 17

basecase 2: matrix 2x2, determinant is the difference between the product of diagonals
Ex:
| a b |
| c d | has a determinant of a*d-b*c
"""
# base case for a 2x2 matrix
if len(matrix) == 2:
return (matrix[0][0]*matrix[1][1]-matrix[0][1]*matrix[1][0])*1.0

# base case for a 1x1 matrix
if len(matrix) == 1 and len(matrix[0]) == 1:
return matrix[0][0]*1.0

determinant = 0
for c in range(len(matrix)):
determinant += ((-1.0)**c) * matrix[0][c] * self.determinant_helper(self.get_minor(matrix, 0, c))
return determinant

def determinant(self):
"""
finds the determinant by using the helper function to supply the current matrix of the object
changes integer floats to integers, and so on
"""
det = self.determinant_helper(self.matrix)
return int(det) if det.is_integer() else det

def inverse(self):
"""
acquires the inverse form of a matrix by using laplace's expansion
which is A^-1 (inverse matrix) = 1/determinant * C^T (transposed matrix of all cofactors of all elements in matrix along main diagonal)
"""
determinant = self.determinant()
matrix = self.matrix
#assert determinant != 0, "Matrix does not have an inverse form"

# base case, for 2x2 matrix
if len(matrix) == 2:
return Matrix(self.dimension, [ [matrix[1][1]/determinant, -1*matrix[0][1]/determinant],
[-1*matrix[1][0]/determinant, matrix[0][0]/determinant] ])

# find matrix of cofactors
cofactors = []
for row in range(len(matrix)):
cofactor_row = []
for column in range(len(matrix)):
minor = self.get_minor(matrix, row, column)
cofactor_row.append( round(((-1)**(row+column)) * self.determinant_helper(minor) / determinant, 2) )
cofactors.append(cofactor_row)
cofactors = self.transposition_along_main_diagonal(cofactors)
return Matrix(self.dimension, cofactors)

class MatrixCalculator:

def __init__(self):
"""
self.matrices               : holds a list of matrices for operations to be performed on.
self.count                  : represents the current prompt number to be displayed for an operation, and determines what is shown next, acts as an index in a list for self.prompts
self.choices                : holds the possible operations for the menu.
self.prompts                : holds the appropriate prompts for depending on whether 1 or 2 matrices should be inputted, and they are accessed using self.count, which is reset after each operation to 0.
self.transposition_choice   : holds the possible transpositions for the menu in transpositions, represents which user input, will call what type of transposition
self.main                   : starts the program

"""
self.matrices = []
self.count = 0
self.choices = {1: self.addition, 2: self.constant_multiplication, 3: self.matrix_multiplication, 4: self.transpose_matrix, 5: self.get_determinant, 6: self.get_inverse, 0: exit}
self.prompts = {1: ['Enter size of matrix: ', 'Enter matrix: '],
2: ['Enter size of first matrix: ', 'Enter size of second matrix: ', 'Enter first matrix: ', 'Enter second matrix: ']}
self.transposition_choice = {1: 'main_diagonal', 2: 'side_diagonal', 3: 'vertical', 4: 'horizontal'}
self.main()

def clear_matrices_and_count(self):
"""
clears the matrices in memory and resets the prompt count after each operation
"""
self.matrices = []
self.count = 0

def display_choices_and_input(self):
"""
displays the menu, and the possible operations, and asks for a response to which operation to perform
self.choices key is located in __init__
"""
print("1. Add matrices\n2. Multiply matrix by a constant\n3. Multiply matrices\n4. Transpose matrix\n5. Calculate a determinant\n6. Inverse matrix\n0. Exit")
self.choices[int(input('Your choice: '))]()

def input_matrix(self, matrix, n):
"""
inputs a matrix using a specific format in command line, and the appropriate response based on the operation
self.prompts refers to the prompts performed based on n which refers to the number of matrices to be inputted
self.count refers to the prompt in this iteration of the program
Ex:
Enter Matrix:
> 1 2 3
> 4 5 6
> 7 8 9
"""
print(self.prompts[n][n + self.count])
for row in range(len(matrix.matrix)):
inp = input().split()
try:
matrix.matrix[row] = list(map(int, inp))
except:
matrix.matrix[row] = list(map(float, inp))
self.count += 1

def input_matrix_n_times(self, n):
"""
allows the inputting of a matrice multiple times with a value of n which refer to the number of matrices to be inputted
n refers to the number of matrices to be inputted and acts as a constant to output specific prompts for each operation
"""
for i in range(n):
self.matrices.append(Matrix(list(map(int, input(self.prompts[n][i]).split()))))
assert len(self.matrices[i].dimension) == 2, "Dimension is a list with two values, rows and columns only"
self.input_matrix(self.matrices[i], n)

def addition(self):
"""
uses the matrix class to add two matrices

Ex:
1. Add matrices
2. Multiply matrix by a constant
3. Multiply matrices
0. Exit
Your choice: 1
Enter size of first matrix: 3 3
Enter first matrix:
1 2 3
4 5 6
7 8 9
Enter size of second matrix: 3 3
Enter second matrix:
1 1 1
1 1 1
1 1 1
The result is:
2 3 4
5 6 7
8 9 10
"""
self.input_matrix_n_times(2)

print('The result is: ')
print(self.matrices[0] + self.matrices[1], "", sep='\n')
self.clear_matrices_and_count()

def constant_multiplication(self):
"""
uses the matrix class to multiply a matrix by a constant

Ex:
1. Add matrices
2. Multiply matrix by a constant
3. Multiply matrices
0. Exit
Your choice: 2
Enter size of matrix: 3 3
Enter matrix:
1 1 1
1 1 1
1 1 1
Enter constant: 1.5
The result is:
1.5 1.5 1.5
1.5 1.5 1.5
1.5 1.5 1.5
"""
self.input_matrix_n_times(1)
constant = input('Enter constant: ')
try:
constant = int(constant)
except:
constant = float(constant)

print('The result is: ')
print(constant * self.matrices[0], "", sep='\n')
self.clear_matrices_and_count()

def matrix_multiplication(self):
"""
uses the matrix class to multiply two matrices together

Ex:
1. Add matrices
2. Multiply matrix by a constant
3. Multiply matrices
0. Exit
Your choice: 3
Enter size of first matrix: 3 3
Enter first matrix:
2 2 2
2 2 2
2 2 2
Enter size of second matrix: 3 3
Enter second matrix:
2 2 2
2 2 2
2 2 2
The result is:
12 12 12
12 12 12
12 12 12
"""
self.input_matrix_n_times(2)
if self.matrices[0].dimension[1] != self.matrices[1].dimension[0]:
print('The operation cannot be performed.', "", sep='\n')
return

print('The result is: ')
print(self.matrices[0] * self.matrices[1], "", sep='\n')
self.clear_matrices_and_count()

def transpose_matrix(self):
"""
Holds the menu for transposing matrices in 4 different ways,
the types of transpositions are stored as a switch case in a dictionary, which is called depending on the user's input of 1-4

Performs transposition and returns the specified transposition requested for.
"""
print("\n1. Main diagonal\n2. Side diagonal\n3. Vertical line\n4. Horizontal line")
transposition_type = self.transposition_choice[int(input('Your choice: '))]
self.input_matrix_n_times(1)

print('The result is: ')
print(self.matrices[0].matrix_transposition(transposition_type), '', sep='\n')
self.clear_matrices_and_count()

def get_determinant(self):
"""
acquires and returns the determinant of an nxn matrix

matrix must have the same number of rows and columns
"""
self.input_matrix_n_times(1)

print('The result is: ')
print(self.matrices[0].determinant(), '', sep='\n')
self.clear_matrices_and_count()

def get_inverse(self):
"""
Acquires the inverse of the matrix, using cofactors and minors

if determinant is 0, then the matrix doesn't have an inverse form
"""
self.input_matrix_n_times(1)
if self.matrices[0].determinant() == 0:
print("This matrix doesn't have an inverse.", '', sep='\n')
return

print('The result is: ')
print(self.matrices[0].inverse(), '', sep='\n')

def main(self):
"""
program runs indefinitely until the exit operation by entering '0' is performed
"""
while True:
self.display_choices_and_input()

MatrixCalculator()


## 1 Answer

The real power of OOP is not that you can use classes as namespaces to bundle functions that belong together. The main power is polymorphism, meaning that you can inherit from classes and use subclasses interchangeably with their parent classes. This means that you want to be able to do something like this:

class UnitaryMatrix(Matrix):
...

a = Matrix(...)
u = UnitaryMatrix(...)
a + u


However, this is currently not possible with your class, because you are using a too restrictive check for the type of classes in your operators. Instead of

assert type(other) == Matrix, f"Matrix cannot be added by {type(other)}"


simply use

assert isinstance(other, Matrix), f"Matrix cannot be added by {type(other)}"


isinstance returns true as long as the object is of that type, or a type derived from it. In other words, a UnitaryMatrix is also a Matrix and can be used wherever a Matrix is expected.

I would re-think how you construct your matrix. You want to make the default usecase as simple as possible. I would want to use your class like this:

m = Matrix([[1, 2], [3, 4]])


Without having to specify the dimensions, since they are obvious from the input. Other ways to construct the matrix should be class methods:

m2 = Matrix.from_flattened([1, 2, 3, 4], shape=(2, 2))
m3 = Matrix.zeros(2, 2)


Which you can implement like this:

class Matrix:
def __init__(self, elements):
self.matrix = elements
self.shape = len(elements), len(elements[0])
...

@classmethod
def from_flattened(self, elements, shape):
assert len(shape) == 2
assert len(elements) == shape[0] * shape[1]
return Matrix([elements[i*shape[0]:(i+1)*shape[0]]
for i in range(shape[0])])

@classmethod
def zeros(self, *shape):
assert len(shape) == 2
return Matrix([[0] * shape[1] for _ in range(shape[0])])


Note that I renamed dimension to shape, which is what e.g. numpy uses. For me, dimension should be len(shape), i.e always two in the case of a matrix.

Depending on your usecases, defining a filled and a ones classmethod might also make sense:

    @classmethod
def filled(self, value, *shape):
assert len(shape) == 2
return Matrix([[value] * shape[1] for _ in range(shape[0])])

@classmethod
def zeros(self, *shape):
return Matrix.filled(0, *shape)

@classmethod
def ones(self, *shape):
return Matrix.filled(1, *shape)


Using class methods also allows you to define other special matrices, like the identity matrix:

    @classmethod
def identity(self, *shape):
m = Matrix.zeros(*shape)
for i in range(m.shape[0]):
m.matrix[i][i] = 1
return m

>>> Matrix.identity(3, 3)
Matrix([[1, 0, 0], [0, 1, 0], [0, 0, 1]])


Using elements == None is not the right way to do it. Use elements is None, as recommended by Python's official style-guide, PEP8.

To ease using your class in an interactive terminal, you should also implement __repr__, which is used as a representation. By convention, the output should be able to construct your class again, i.e. eval(repr(m)) == m. In this case this is rather easy:

class Matrix:
...

def __repr__(self):
return f"Matrix({self.matrix!r})"

>>> Matrix.zeros(2, 2)
Matrix([[0, 0], [0, 0]])


Instead of using * both for scalar and matrix multiplication, you could implement the (normally unused) operator @, which is called matrix multiplication. In order to do so, simply implement the dunder method __matmul__. Even if you want * to do both things, I would implement __matmul__ anyway and just use self @ other in the definition of __mul__.

Use the built-in sum if you sum up things in a loop:

determinant = sum((-1)**c * matrix[0][c] * self.determinant_helper(self.get_minor(matrix, 0, c))
for c in range(len(matrix)))


You can also iterate over the entries and indices at the same time using enumerate:

determinant = sum((-1)**i * m_0i * self.determinant_helper(self.get_minor(matrix, 0, i))
for i, m_0i in enumerate(matrix[0]))


I would consider making the determinant method a property. This allows you to easily make it cached in the future:

class Matrix:
...

@property
def determinant(self):
"""
finds the determinant by using the helper function to supply the current matrix of the object
changes integer floats to integers, and so on
"""
det = self.determinant_helper(self.matrix)
return int(det) if det.is_integer() else det

def inverse(self):
"""
acquires the inverse form of a matrix by using laplace's expansion
which is A^-1 (inverse matrix) = 1/determinant * C^T (transposed matrix of all cofactors of all elements in matrix along main diagonal)
"""
determinant = self.determinant
...


In order to cache the result, you just need to use a different decorator (Python 3.8+):

from functools import cached_property

...

class Matrix:
...

@cached_property
def determinant(self):
...