Част 4 от моята поредица за програмиране за пакета Python swiftex за писане на математика в Jupyter. Това е толкова бързо, колкото с молив и хартия.

За предишната част следвайте тази връзка.

В моята скорошна статия „Числени производни направени правилно“ използвах следната формула, като първоначално я написах в LaTeX. Ако сте използвали LaTeX преди, тогава знаете колко е досадно да пишете такива изрази. И ако искате да го редактирате, трябва да започнете отначало, защото е толкова сложно. В днешната статия ще подобрим пакета swiftex така, че писането на матрици в Jupyter и експортирането им в LaTeX е просто парче торта.

Константни матрици

Започваме с възможно най-лесните матрици: постоянните. Например, напишете тест за матрица 2x2 с всички записи, зададени на 1:

class TestMatrix(unittest.TestCase):

    def test_constant_matrix(self):
        A = Matrix(2, 2, default=1)
        expected = r'\left(' \
                   r'\begin{matrix}' \
                   r'1 & 1 \\' \
                   r'1 & 1 ' \
                   r'\end{matrix}' \
                   r'\right)'
        self.assertEqual(expected, A)

Входът трябва да бъде само един ред, съответстващ на 6 реда от LaTeX изход. Като реализация въвеждаме друг клас Matrix, наследяващ от Symbol:

class Matrix(Symbol):

    def __init__(self, nrows, ncols, default='', **kwargs):
        self.values = {}
        self.nrows = nrows
        self.ncols = ncols

        for i in range(nrows):
            for j in range(ncols):
                self.values[(i, j)] = Symbol(str(default))

        super().__init__(str(self), **kwargs)

    def __str__(self):
        s = r'\left(\begin{matrix}'
        for i in range(self.nrows):
            for j in range(self.ncols):
                s += str(self.values[i, j])
                if j < self.ncols - 1:
                    s += ' & '
                elif i < self.nrows - 1:
                    s += r'\\'
        s += r'\end{matrix}\right)'
        return s

    def __repr__(self):
        return self.__str__()

Реших да заменя функциите за преобразуване и представяне на низове от родителския клас, защото, добре, матриците са малко по-сложни. :-) Записите на матрицата са всички Symbol обекти, събрани в речник и могат да бъдат достъпни чрез индекс 2-кортеж. Това е всичко за постоянните матрици, но това все още не е много полезно.

Задаване на стойности на матрицата

Имаме нужда от начин да зададем единични стойности на матрица. Това трябва да изглежда така в следния тест:

def test_set_matrix_by_index(self):

    A = Matrix(2, 2, default=0) 
    A[0, 1] = 1
    A[1, 0] = 1

    expected = r'\left(' \
               r'\begin{matrix}' \
               r'0 & 1 \\' \
               r'1 & 0 ' \
               r'\end{matrix}' \
               r'\right)'
    self.assertEqual(expected, A)

Тук инициализираме постоянна нулева матрица и заменяме 2 записа. Изпълнението на теста сега дава следната грешка:

A[0, 1] = 1
TypeError: 'Matrix' object does not support item assignment

Трябва да внедрим метода __setitem__, за да работи това:

def __setitem__(self, idx, value):
        self.values[idx] = Symbol(str(value))

С това на място нашата 2x2 матрица A изглежда така:

Matrix Slices

Добре, засега има само незначително предимство пред писането на чист LaTeX. Но това се променя веднага щом позволим срезове като аргументи. Помислете за следния тест:

def test_matrix_slice_by_index(self):
        A = Matrix(3, 3, default=0)
        A[1, :] = 1
        expected = B = Matrix(3, 3, default=0)
        B[1, 0] = 1
        B[1, 1] = 1
        B[1, 2] = 1
        self.assertEqual(expected, A)

Тук дефинираме матрица 3x3 A с всички стойности 0 и след това задаваме всички стойности на втория ред на 1. Това означава операторът за нарязване :. Така че нашата реализация трябва да вземе предвид, че аргументите на оператора [] могат да бъдат срезове, които са вградени обекти в Python. В момента изпълнението на нашия тест дава грешката:

self.values[idx] = Symbol(str(value))
TypeError: unhashable type: 'slice'

Това е така, защото все още не сме подготвени за резени. Можем да се адаптираме, като променим изпълнението на метода __setitem__:

def __setitem__(self, idx, value):
        assert len(idx) == 2
        if isinstance(idx[0], slice):
            if idx[0].start is None:
                start = 0
            else:
                start = idx[0].start
            if idx[0].stop is None:
                stop = self.nrows
            else:
                stop = idx[0].stop
            row_inds = list(range(start, stop))
        else:
            row_inds = [idx[0]]

        if isinstance(idx[1], slice):
            if idx[1].start is None:
                start = 0
            else:
                start = idx[1].start
            if idx[1].stop is None:
                stop = self.ncols
            else:
                stop = idx[1].stop
            col_inds = list(range(start, stop))
        else:
            col_inds = [idx[1]]

        for i in row_inds:
            for j in col_inds:
                self.values[i, j] = Symbol(str(value))

Срезовите обекти имат start и stop атрибут, което означава индексите, откъдето да започне и спре среза. Задаването на един от тях на None съответства на пропускане на стойността в израза на среза. Например имаме

3:7 <==> start == 3, stop == 7
:5 <==> start == None, stop == 5
: <==> start == None, stop == None

Всички тестове преминават успешно, но това е много грозен код и голяма част от него е излишен. Преди да обясня какво прави кодът, забележете, че двата if-блока са почти еднакви. Перфектен за рефакторинг. Така че нека напишем частен метод _expand_indices :

def _expand_slice(self, slice_or_ind, max_vals):
        if isinstance(slice_or_ind, slice):
            if slice_or_ind.start is None:
                start = 1
            else:
                start = slice_or_ind.start
            if slice_or_ind.stop is None:
                stop = max_vals
            else:
                stop = slice_or_ind.stop
            inds = list(range(start, stop))
        else:
            inds = [slice_or_ind]
        return inds

и заменете if-блоковете с извиквания на този метод.

def __setitem__(self, idx, value):
        assert len(idx) == 2

        row_inds = self._expand_slice(idx[0], self.nrows)
        col_inds = self._expand_slice(idx[1], self.ncols)

        for i in row_inds:
            for j in col_inds:
                self.values[i, j] = Symbol(str(value))

Сега е малко по-ясно какво се случва. В случай, че единият или и двата аргумента (idxе кортеж!) е срез (като : или 2: или :-1), _expand_slice преобразува среза в списък от индекси, който съответства на този срез. След това преминаваме през всички индекси на среза (или може би само един като за единичен запис) и присвояваме value на всички тези матрични записи.

Избирането на срезове и присвояването на постоянна стойност е хубаво, но би било още по-хубаво, ако можем да присвоим и последователност, както в следващия тест.

def test_matrix_slice_with_sequence(self):
        A = Matrix(3, 3, default=0)
        A[1, :] = 1, 2, 3
        expected = B = Matrix(3, 3, default=0)
        B[1, 0] = 1
        B[1, 1] = 2
        B[1, 2] = 3
        self.assertEqual(str(expected), str(A))

Това може лесно да се вземе предвид чрез модифициране на изпълнението на __setitem__ :

def __setitem__(self, idx, value):
        assert len(idx) == 2

        row_inds = self._expand_slice(idx[0], self.nrows)
        col_inds = self._expand_slice(idx[1], self.ncols)

        k = 0
        for i in row_inds:

            for j in col_inds:
                if hasattr(value, '__len__'):
                    self.values[i, j] = Symbol(str(value[k]))
                else:
                    self.values[i, j] = Symbol(str(value))
                k += 1

Ако стойността, която се опитваме да присвоим, е итерируема (т.е. може да се използва като аргумент на вградената функция len), тогава присвояваме всяка стойност една след друга, в противен случай правим същото както преди.

брекети

Преди да завърша тази статия, има още едно нещо, на което искам да се спра, което може да се появи неочаквано, но ще ми трябва за последния пример в този текст. Досега, когато казвам

(a + b)**2

Резултатът е

Това не е това, което възнамеряваме. Това е така, защото скобите на Python не могат да бъдат разрешени и се използват само от интерпретатора за определяне на приоритета на оператора. Като бързо решение, нека дефинираме изрична функция за писане на скоби, както в следния тест:

def test_brace(self):
    actual = brace(a+b)**2
    self.assertEqual('(a + b)^2', actual)

Изпълнението е малка функция:

def brace(expr):
    return r'\left(' * expr * r'\right)'

Нещо по-сложно

И накрая, нека приложим нашия нов клас към по-сложния случай на употреба, споменат в началото:

добиви

И ако някога имате нужда от същото нещо като чист LaTeX, просто приложете функцията print:

print(A)
Out: \left(\begin{matrix}1 & 1 & 1 & 1 & 1 & 1\\- p & - p + 1 & - p + 2 & \dots & q - 1 & q\\\left( - p \right)^{2} & \left( - p + 1 \right)^{2} & \left( - p + 2 \right)^{2} & \dots & \left( q - 1 \right)^{2} & q^{2}\\\dots & \dots & \dots & \dots & \dots & \dots\\\left( - p \right)^{p + q - 1} & \left( - p + 1 \right)^{p + q - 1} & \left( - p + 2 \right)^{p + q - 1} & \dots & \left( q - 1 \right)^{p + q - 1} & q^{p + q - 1}\\\left( - p \right)^{p + q} & \left( - p + 1 \right)^{p + q} & \left( - p + 2 \right)^{p + q} & \dots & \left( q - 1 \right)^{p + q} & q^{p + q}\end{matrix}\right)

Пълен изходен код

Както винаги, можете да получите пълния изходен код от моето хранилище на GitHub. Просто проверете

git checkout 384d586d9a2f

за да видите състоянието в края на тази статия.

Благодаря за четенето и очаквайте следващата сесия!

Повече съдържание в plainenglish.io. Регистрирайте се за нашия безплатен седмичен бюлетин тук.