OOP Matrix Challenge

Download exercises zip

Browse files online

We want to model operations on IMMUTABLE matrices, the object oriented way.

You have a generic interface called Matrix in file matrix.py, and you have to implement two descendants of the interface, DenseMatrix and SparseMatrix.

IMPORTANT: work in group and share tests!

You will make a lot of mistakes, some hard to debug: help yourself by writing test cases, we also invite you to share the tests (not the solutions!) with your fellow students.

Please note there are many cornercases, even if stuff seems to work there will probably occasions where it fails - you won’t probably be able to find them all on your own.

What to do

  • unzip exercises in a folder, you should get something like this:

oop
   matrix.py
   oop-matrix-chal.ipynb
   .
   .

You can ignore the notebook, and edit .py files in Visual Studio Code.

DenseMatrix

Let’s begin with the simpler implementation, which is DenseMatrix: you will code it by storing data inside lists of lists.

Create a new file dense_matrix.py and declare a class DenseMatrix which inherits from Matrix. Copy inside all the methods definitions from Matrix.

from matrix import Matrix

class DenseMatrix(Matrix):
    """ A Matrix represented internally as a list of lists

        When creating it from list of lists, perform a DEEP COPY of the input
        and store it inside a private field you will call  _cells (use ONE underscore)
    """

Constructors and printing

Generally first methods we implement are the constructor and methods to show the class content such as __str__ and __repr__. All methods are documented in the matrix.py interface, let’s see now the constructor. Copy paste the following inside your DenseMatrix class.

def __init__(self, *args):
        """Initializes the Matrix with either:

            One argument: a list of lists
            Three arguments: number of rows
                             number of columns
                             a list of cell triplets (row_index,col_index,value)
                            - if number of rows or columns is less than indeces found
                              in triplets, raise Value Error

            In both cases, provided data must allow the creation of a matrix with
            at least one row and a column, otherwise raise ValueError
        """
        raise Exception("Should be implemented in a descendant of Matrix!")

Constructor as list of lists

We see the the constructor takes the special *args argument, which means it can accept a variable number of arguments. To check for the two cases, you might write something like this:

class DenseMatrix(Matrix):
    def __init__(self, *args):

        if len(args) == 1:   # list of lists
            rows = args[0]
            # do something ...
        elif len(args) == 3:
            n,m,triplets = args
            # do something
        else:
            raise ValueError('Expected one or three args!')

You can start by handling only the one argument case, which we will interpret as a list of lists. You can store a deep copy of the list of lists in a private field you can call _cells. Why should we store a deep copy? It’s a form a so-called defensive-programming: if we store a copy, it’s less likely that a user of our class will later mess with the instance internal data. If we store the data in a field beginning with one underscore _, we will signal external users we do not want them to directly access it (notice if they really want they will still be able to do it)

[1]:
from dense_matrix_chal_sol import *
from sparse_matrix_chal_sol import *
[2]:
dma = DenseMatrix([ [0,6,8,0],
                    [0,0,0,5],
                    [9,0,7,0] ])
[3]:
dma
[3]:
DenseMatrix([[0, 6, 8, 0], [0, 0, 0, 5], [9, 0, 7, 0]])
[4]:
type(DenseMatrix)
[4]:
type

str and repr

Implement __str__ and __repr__

def __str__(self):
    """ RETURN a nice human-readable formatted string, possibly like this:

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

          - substitute Matrix with the descendant class name
          - NOTE: sometimes this representation is impractical (i.e. sparse matrices
                  with large n/m), in that case use another format of your choice
    """
    raise Exception("Should be implemented in a descendant of Matrix!")

def __repr__(self):
    """ RETURN one-line string representing a Python expression which would recreate the atrix
    """
    raise Exception("Should be implemented in a descendant of Matrix!")
[5]:
str(dma)
[5]:
'DenseMatrix [ [0, 6, 8, 0]\n              [0, 0, 0, 5]\n              [9, 0, 7, 0] ]'
[6]:
print(dma)
DenseMatrix [ [0, 6, 8, 0]
              [0, 0, 0, 5]
              [9, 0, 7, 0] ]
[7]:
repr(dma)
[7]:
'DenseMatrix([[0, 6, 8, 0], [0, 0, 0, 5], [9, 0, 7, 0]])'
[8]:
dma
[8]:
DenseMatrix([[0, 6, 8, 0], [0, 0, 0, 5], [9, 0, 7, 0]])

constructor as list of triplets

Now extend the constructor to handle also the list of triplets case:

[9]:
dmb = DenseMatrix(3,4, [(0,1,6),(0,2,8),(1,3,5),(2,0,9),(2,2,7)])
[10]:
dmb
[10]:
DenseMatrix([[0, 6, 8, 0], [0, 0, 0, 5], [9, 0, 7, 0]])
[11]:
print(dmb)
DenseMatrix [ [0, 6, 8, 0]
              [0, 0, 0, 5]
              [9, 0, 7, 0] ]

shape

Implement shape method:

def shape(self):
    """RETURN the number of rows and columns as a tuple
    """
[12]:
dma.shape()  # NOTE the ()
[12]:
(3, 4)

Brackets operator

Override the square bracket operator by reimplementing the method __getitem__

IMPORTANT: Read carefully Python documentation!!

def __getitem__(self, key):
    """Overrides default bracket access behaviour.
       key is whatever the user passes within the brackets, in our case
       we want user to be able to write

       my_mat[2,5]  to access element at row 2 and column 5

       NOTE 1: if the user types 2,5 inside the brackets, Python will actually
             generate a TUPLE (2,5)  so that is the key type you should
             accept (a tuple of *exactly two* integers)
       NOTE 2: Since this method signature is defined by Python documention:

          https://docs.python.org/3/reference/datamodel.html#object.__getitem__

        you MUST write it respecting ALL the indications of the docs, in particular
        think also about  what should happen in undesired scenarios when
        the user enters a key of wrong type, wrong value, etc as described
        in the documentation
    """
[13]:
print(dma)
DenseMatrix [ [0, 6, 8, 0]
              [0, 0, 0, 5]
              [9, 0, 7, 0] ]
[14]:
dma[0,1]
[14]:
6
[15]:
dma[0,2]
[15]:
8

Negative indeces should also be supported:

[16]:
dma[0,-3]
[16]:
6

nonzero

def nonzero(self):
        """Return a list of triplets (row index, column index, value) of non-zero cells,
           in no particular order.
        """

Since we stored the rows in a private field _cells, we have to offer users other ways to access data. For example, by calling nonzero() method users should be able to obtain non-zero values, as a list of triplets with row index, column index and value of cells. You might wonder what the utility is, after we already implemented the brackets operator: the reason will become clearer when you start implementing methods that need to visit a generic Matrix, like the following ones.

[17]:
print(dma)
DenseMatrix [ [0, 6, 8, 0]
              [0, 0, 0, 5]
              [9, 0, 7, 0] ]
[18]:
dma.nonzero()
[18]:
[(0, 1, 6), (0, 2, 8), (1, 3, 5), (2, 0, 9), (2, 2, 7)]

isclose

Implement this method:

DO NOT MAKE ASSUMPTIONS ABOUT other CLASS!

In the implementation, use other like if it were a generic Matrix, it might not always be a DenseMatrix. So the only way to access elements will be either by using the brackets operator or by calling nonzero()

def isclose(self, other, delta):
        """ RETURN True if each cell in this matrix is within a delta distance
            from other Matrix. RETURN False if any cell couple differs more than delta.

            - if matrices have different dimensions, raise ValueError
        """
[19]:
print(dma)
DenseMatrix [ [0, 6, 8, 0]
              [0, 0, 0, 5]
              [9, 0, 7, 0] ]
[20]:
dmd = DenseMatrix([ [0,     5.9,   8,   0],
                    [0,       0, 0.1,   5],
                    [9.15, -0.1,   7, 0.1] ])

dma.isclose(dmd, 0.2)
[20]:
True
[21]:
dma.isclose(dmd, 0.05)
[21]:
False

Equality

Override == operator:

[22]:

dma = DenseMatrix([ [0,6,8,0],
                    [0,0,0,5],
                    [9,0,7,0] ])

dmb = DenseMatrix(3,4, [(0,1,6),(0,2,8),(1,3,5),(2,0,9),(2,2,7)])

dma == dmb
[22]:
True

Notice in this case, since we want in general to check equality to the last bit, we will want to check that other is actually a DenseMatrix, otherwise we will return False

[23]:
dma == 'ciao'
[23]:
False

Equality with a different shape should just return False, without raising exceptions:

[24]:
dma == DenseMatrix([[2,4],
                    [9,3]])
[24]:
False

Equality with different numbers:

[25]:
dmc = DenseMatrix([ [1,2,3,4],
                    [6,7,8,9],
                    [10,11,12,13] ])
dma == dmc
[25]:
False

Sum

Override + operator so can sum other Matrix instances:

DO NOT MAKE ASSUMPTIONS ABOUT other CLASS!

In the implementation, use other like if it were a generic Matrix, it might not always be a DenseMatrix. So the only way to access elements will be either by using the brackets operator or by calling nonzero()

[26]:
dma = DenseMatrix([ [0,6,8,0],
                    [0,0,0,5],
                    [9,0,7,0] ])

dmc = DenseMatrix([ [1,2,3,4],
                    [6,7,8,9],
                    [10,11,12,13] ])
[27]:
print(dma + dmc)
DenseMatrix [ [1, 8, 11, 4]
              [6, 7, 8, 14]
              [19, 11, 19, 13] ]

Note dma and dmc where not modified:

[28]:
print(dma)
DenseMatrix [ [0, 6, 8, 0]
              [0, 0, 0, 5]
              [9, 0, 7, 0] ]
[29]:
print(dmc)
DenseMatrix [ [1, 2, 3, 4]
              [6, 7, 8, 9]
              [10, 11, 12, 13] ]

Multiplication

Override * operator so we can multiply by a scalar:

[30]:
print(dma * 2)
DenseMatrix [ [0, 12, 16, 0]
              [0, 0, 0, 10]
              [18, 0, 14, 0] ]

DO NOT MAKE ASSUMPTIONS ABOUT other CLASS!

In the implementation, use other like if it were a generic Matrix, it might not always be a DenseMatrix. So the only way to access elements will be either by using the brackets operator or by calling nonzero()

Again multiplication by a scalar does not modify the original:

[31]:
print(dma)
DenseMatrix [ [0, 6, 8, 0]
              [0, 0, 0, 5]
              [9, 0, 7, 0] ]

Multiplying a scalar by a matrix should also work:

[32]:
print(2 * dma)
DenseMatrix [ [0, 12, 16, 0]
              [0, 0, 0, 10]
              [18, 0, 14, 0] ]

Again original dma was not changed:

[33]:
print(dma)
DenseMatrix [ [0, 6, 8, 0]
              [0, 0, 0, 5]
              [9, 0, 7, 0] ]

Matrix vector multiplication

Next implement support for multiplication of a matrix by a properly dimensioned vector on the right, which must have the same number of rows as the matrix number of columns:

     matrix   vector      result

\j    012
i  0  aaa        v0       a00*v0 + a01*v1 + a02*v2
   1  aaa        v1       a10*v0 + a11*v1 + a12*v2
   2  aaa        v2       a20*v0 + a21*v1 + a22*v2
   3  aaa                 a30*v0 + a31*v1 + a32*v2

The result will have the same number of rows as the matrix.

DO NOT MAKE ASSUMPTIONS ABOUT other CLASS!

In the implementation, use other like if it were a generic Matrix, it might not always be a DenseMatrix. So the only way to access elements will be either by using the brackets operator or by calling nonzero()

[34]:
dmveca = DenseMatrix([[1],[2],[3],[4]])
[35]:
print(dmveca)
DenseMatrix [ [1]
              [2]
              [3]
              [4] ]
[36]:
print(dma)
DenseMatrix [ [0, 6, 8, 0]
              [0, 0, 0, 5]
              [9, 0, 7, 0] ]
[37]:
print(dma * dmveca)
DenseMatrix [ [36]
              [20]
              [30] ]

Multiplication by a vector does not modify the originals:

[38]:
print(dmveca)
DenseMatrix [ [1]
              [2]
              [3]
              [4] ]

Notice dimensions must be compatible, this should not work:

>>> print(dma*DenseMatrix([[1],[2]]))

---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-33-bc850efec5f1> in <module>()
----> 1 print(dma*DenseMatrix([[1],[2]]))

TypeError: unsupported operand type(s) for *: 'DenseMatrix' and 'DenseMatrix'

Vector matrix multiplication

Next implement support for multiplication of a matrix by a properly dimensioned horizontal vector on the left, which must have the same number of columns as the matrix number of rows:

  ector matrix result

\j  012   0123 0123
i   vvv 0 aaaa rrrr
        1 aaaa
        2 aaaa


res = a00*v0+a10*v1+a20*v2, a01*v0+a11*v1+a21*v2, a02*v0+a12*v1+a22*v2, a03*v0+a13*v1+a23*v2

The result will have the same number of columns as the matrix.

DO NOT MAKE ASSUMPTIONS ABOUT other CLASS!

In the implementation, use other like if it were a generic Matrix, it might not always be a DenseMatrix. So the only way to access elements will be either by using the brackets operator or by calling nonzero()

[39]:
print(dma)
DenseMatrix [ [0, 6, 8, 0]
              [0, 0, 0, 5]
              [9, 0, 7, 0] ]
[40]:
dmvecb = DenseMatrix([[1,2,3]])
[41]:
print(dmvecb * dma)
DenseMatrix [ [27, 6, 29, 10] ]

Matrix matrix multiplication

This would be the general problem but for the purposes of this challenge, it is not mandatory to support multiplication by a matrix, even if they have compatible dimensions:

>>> print(dma*DenseMatrix([[3,2],
                           [3,7],
                           [2,8],
                           [9,3],]))

---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-34-ed1ad440c418> in <module>()
      2                        [3,7],
      3                        [2,8],
----> 4                        [9,3],]))

TypeError: unsupported operand type(s) for *: 'DenseMatrix' and 'DenseMatrix'

SparseMatrix

If you’ve arrived so far, congratulations, that was quite a journey. But only half of it! We’ve seen matrices as lists of lists for a long time, let’s now see if we can save some space. Many real-world matrices have often most cells set to zero, so it would be convenient to avoid storing in memory those cells, and reserve space only for non-zero valued cells. There are many ways to do it (see Wikipedia), in this challenge we will adopt the simplest which is the so-called Dictionary of Keys (DOK in short). DOK consists of a dictionary that maps (row, column)-pairs to the value of the elements. Elements that are missing from the dictionary are assumed to be zero.

Now we can see the real reason why we supported also a constructor with triplets: if the goal of using a SparseMatrix is to avoid creating huge tables in memory, initializing it with a list of lists would defeat the purpose! In what follows, sometimes we may still use the list of lists initialization, but that would only be for clarity purposes.

Sparse constructors and printing

Create a new file sparse_matrix.py and copy inside this with the method definitions you find in matrix.py as done before.

from matrix import Matrix

class SparseMatrix(Matrix):
    """ A SparseMatrix is a matrix which contains very few non-zero elements.

        In this implementation, storage is modelled as a dictionary of keys (DOK in short),
        where each key is a tuple of cell coordinates which maps to a non-zero cell value.

        NOTE: *NEVER* store zero-valued cells.
    """

Ffollow the same implementation order as before, so start with the constructor __init__, __str__ and __repr__ overrides. Note this time our class implementation will have a dictionary as private _cells field.

With sparse matrices we can have a big 30x20 matrix and occupy very little memory:

[42]:
sma = SparseMatrix(30,20, [(26,17,3),(24,11,5)])

You can save the shape inside a private _shape field.

Since shape can be much bigger than atcual data, with __str__ you must show the internal dictionary and the shape:

[43]:
print(sma)
SparseMatrix {
    (24, 11): 5,
    (26, 17): 3
}
shape: (30, 20)

__repr__ will show the triplets constructor:

[44]:
repr(sma)
[44]:
'SparseMatrix(30,20, [(24, 11, 5), (26, 17, 3)])'
[45]:
sma
[45]:
SparseMatrix(30,20, [(24, 11, 5), (26, 17, 3)])

Sparse shape

Implement shape():

[46]:
sma.shape()   # note the round brackets
[46]:
(30, 20)

Sparse Brackets operator

Override the square bracket operator by reimplementing the method __getitem__

CAREFUL!

Even more than before, you have to super-carefully read Python documentation!!

To respect Python documentation, this time you can’t rely on built-in errors and features of lists as you probably did in the DenseMatrix case, so you will have to write a lot of if that check for various possible errors (bad types, values, etc) and raise the appropriate errors (IndexError, TypeError, KeyError, etc)

Implement brackets operator:

[47]:
sma[24,11]
[47]:
5
[48]:
sma[26,17]
[48]:
3
[49]:
sma[29,3]   # shouldn't explode
[49]:
0.0

Support negative numbers:

[50]:
sma[-6,11]
[50]:
5
[51]:
sma[29,-2]
[51]:
0.0

Sparse nonzero

This is very important, as it allows iterating the data structure in an efficient way - depending on the shape, trying all the couples i, j with the square brackets might be prohibitively costly.

[52]:
sma.nonzero()
[52]:
[(24, 11, 5), (26, 17, 3)]

Sparse isclose

Be careful to write generic code, and avoid iterating all possible i,j couples - you should only look into nonzero ones.

[53]:
print(sma)
SparseMatrix {
    (24, 11): 5,
    (26, 17): 3
}
shape: (30, 20)
[54]:
sma.isclose(SparseMatrix(30,20, [(24, 11, 4.9), (26, 17, 3.1)]), 0.2)
[54]:
True
[55]:
sma.isclose(SparseMatrix(30,20, [(24, 11, 4.9), (26, 17, 3.1)]), 0.05)
[55]:
False
[56]:
sma.isclose(SparseMatrix(30,20, [(24, 11, 4.9), (26, 17, 3.1), (13,18,0.5)]), 0.2)
[56]:
False
[57]:
sma.isclose(SparseMatrix(30,20, [(24, 11, 4.9), (26, 17, 3.1), (13,18,0.1)]), 0.2)
[57]:
True
[58]:
sma.isclose(SparseMatrix(30,20, [(24, 11, 4.9), (26, 17, 3.1), (13,18,-0.1)]), 0.2)
[58]:
True

The interesting part comes when we try isclose with another class, like DenseMatrix. If you wrote general enough code, only assuming other is a generic Matrix the following should work without particular effort:

[59]:
smp = SparseMatrix([[1,    0,   3],
                    [4,    5,   0]])
dmp = DenseMatrix( [[1.1,  0.1, 2.9],
                    [4.05, 4.9, -0.05]])
[60]:
smp.isclose(dmp, 0.2)
[60]:
True
[61]:
smp.isclose(dmp, 0.05)
[61]:
False
[62]:
dmp.isclose(smp, 0.2)
[62]:
True
[63]:
dmp.isclose(smp, 0.05)
[63]:
False

Sparse equality

Be careful to write generic code, and avoid iterating all possible i,j couples - you should only look into nonzero ones.

[64]:
sma
[64]:
SparseMatrix(30,20, [(24, 11, 5), (26, 17, 3)])
[65]:
smb = SparseMatrix(30,20, [(24, 11, 5), (26, 17, 3)])
[66]:
sma == sma
[66]:
True
[67]:
sma == smb
[67]:
True
[68]:
sma == SparseMatrix(30,20, [(24, 11, 5), (26, 17, 123)])
[68]:
False
[69]:
sma == SparseMatrix(30,20, [(24, 11, 5), (26, 17, 3), (5,10,6)])
[69]:
False

Equality among different types should always be False, even if they descend from the same ancestor:

[70]:
SparseMatrix([[1,2],[3,4]]) == DenseMatrix([[1,2],[3,4]])
[70]:
False
[71]:
DenseMatrix([[1,2],[3,4]]) == SparseMatrix([[1,2],[3,4]])
[71]:
False

Sparse sum

Be careful to write generic code, and avoid iterating all possible i,j couples - you should only look into nonzero ones.

[72]:
smq = SparseMatrix([[1,2,0],
                    [4,0,6]])
dmq = DenseMatrix([[0,8,0],
                   [0,7,3]])
[73]:
print(smq + smq)
SparseMatrix {
    (0, 1): 4,
    (1, 2): 12,
    (1, 0): 8,
    (0, 0): 2
}
shape: (2, 3)

Again the interesting bit is when you try summing among different classes:

[74]:
print(smq + dmq)
SparseMatrix {
    (0, 1): 10,
    (1, 2): 9,
    (1, 0): 4,
    (0, 0): 1,
    (1, 1): 7
}
shape: (2, 3)
[75]:
print(dmq + smq)
DenseMatrix [ [1, 10, 0.0]
              [4, 7.0, 9] ]

Sparse multiplication

As always, be careful writing general code, and avoid iterating all possible i,j couples - you should only look into nonzero ones.

Sparse matrix vector multiplication

[76]:
smq = SparseMatrix([[1,0,3],
                    [4,5,0]])

smvecq = SparseMatrix([[1],
                       [0],
                       [3]])
print(smq * smvecq)
SparseMatrix {
    (1, 0): 4.0,
    (0, 0): 10
}
shape: (2, 1)

Let’s try mixing types:

[77]:
dmq = DenseMatrix([[1,0,3],
                   [4,5,0]])

dmvecq = DenseMatrix([[1],
                      [0],
                      [3]])
print(smq * dmvecq)
SparseMatrix {
    (1, 0): 4,
    (0, 0): 10
}
shape: (2, 1)
[78]:
print(dmq * smvecq)
DenseMatrix [ [10.0]
              [4.0] ]

Sparse vector matrix multiplication

[79]:
smvecr = SparseMatrix([[1,2,0]])

smr = SparseMatrix([ [0, 6, 8, 0],
                     [0, 0, 0, 5],
                     [9, 0, 7, 0] ])

print(smvecr * smr)
SparseMatrix {
    (0, 1): 6,
    (0, 3): 10,
    (0, 2): 8.0
}
shape: (1, 4)

Let’s mix types:

[80]:
dmvecr = DenseMatrix([[1,2,0]])

dmr = DenseMatrix([ [0, 6, 8, 0],
                    [0, 0, 0, 5],
                    [9, 0, 7, 0] ])

print(smvecr * dmr)
SparseMatrix {
    (0, 1): 6,
    (0, 3): 10,
    (0, 2): 8.0
}
shape: (1, 4)
[81]:
print(dmvecr * smr)
DenseMatrix [ [0, 6, 8, 10] ]
[ ]: