1
1

More than 1 year has passed since last update.

Python のマジックメソッドをオーバーライドして行列の四則演算を読みやすくする

Last updated at Posted at 2022-05-16

Python に限らないと思いますが、 Python では print 関数や四則演算、比較等の動作を独自に定義することができます。
これはマジックメソッドと呼ばれ、 __add____str__ など、アンダースコア 2 個ずつで囲まれています。
これにより、自分で作ったベクトルクラスに対して + を用いた足し算を定義したり、商品クラスに対して print 関数で商品名を出力するようにしたりできます。

マジックメソッドについてはこちらの記事が参考になります。
https://python-course.eu/oop/magic-methods.php

今回は 行列 クラスを定義し、四則演算等を定義してみます。

なお、この記事では Python のクラスに関する知識を前提とします。

(2022/05/16 追記)
行列演算は Numpy という便利なライブラリが既にあります。

行列

行列は、下のように数(や記号や式)たちが長方形の形で整列しているもののことです。
行は横の並び(1 2 34 5 6)、列は縦の並び(1 42 53 6)を表します。
この場合、横の並びが 2 つ、縦の並びが 3 つあるので、「2 行 3 列の行列」や「2 × 3 型の行列」と呼ばれることもあります。

1 つ 1 つの数字(や記号や式)は成分と呼びます。
例えば、下の図で「1」は 1 行目にあり 1 列目にあるので「(1, 1) 成分」、「6」は 2 行目にあり 3 列目にあるので「(2, 3) 成分」と呼びます。

\begin{pmatrix}
1 & 2 & 3 \\
4 & 5 & 6
\end{pmatrix}

行列の概念は、物理学や統計学など、科学的な分野で広く使われています(詳しくは省略)。
今私たちが見ている PC やスマホのディスプレイは長方形状に並んだドットの集まりでできているので、例えば 768 × 1024 型行列の各要素をカラーコードに置き換えれば、それは 1024 × 768 ドット画面の表示内容をおおむね表す行列とも言えます。

行列を配列で考える

先ほどの 2 × 3 型の行列を、配列で表してみます。
行数が 2 ということと、配列の要素数が 2 ということが一致します。
また、列数が 3 ということと、配列の各要素をなす配列の要素数が 3 ということが一致します。

[
    [1, 2, 3],
    [4, 5, 6]
]

実装内容

今回、行列に対して以下の演算や関数を定義することにしましょう。

  • print: 行列の出力
  • +: 足し算
  • -: 引き算
  • *: 掛け算
  • /: 割り算(逆行列を掛ける)
  • **: べき乗(-1 の場合は逆行列を求める)

行列クラスの作成

コンストラクタ

行列の各数字を 2 次元の配列にして、コンストラクタの引数に渡し、インスタンス変数に設定します。
なお、空の配列の場合・行ごとに列数がバラバラになっている場合は例外を返すようにします。

matrix.py
from typing import List


class Matrix:
    """行列クラス。

    行列の演算をここで定義します。

    例えば 2 行 3 列の行列を作りたい場合、次のようにします。 ::

        matrix = Matrix([
            [1, 2, 3],
            [4, 5, 6],
        ])

    **各行の長さはすべて同じでなければなりません。**

    :ivar count_row: 行数
    :ivar count_col: 列数
    :ivar rows: 行列自身
    """

    def __init__(self, rows: List[List[float]]) -> None:
        """

        :param rows:
        :raises ValueError if the matrix is invalid
        """
        self.__set_rows(rows)

    def __set_rows(self, rows: List[List[float]]) -> None:
        """バリデーションを行い、インスタンス変数を設定します。

        :param rows:
        :raises ValueError if the matrix is invalid
        """
        # validate input
        count_row = len(rows)
        if count_row == 0:
            raise ValueError('matrix cannot be empty')
        count_col = len(rows[0])
        if count_col == 0:
            raise ValueError('matrix cannot be empty')
        for i in range(1, len(rows)):
            if len(rows[i]) != count_col:
                raise ValueError('all rows must be the same length')

        self.count_row = count_row
        self.count_col = count_col
        self.rows = rows

行列の出力

出力形式にも様々あるかとは思いますが、今回は人間が見やすい形での出力を目指します。

[[1, 2, 3],[4, 5, 6]]

のように配列をそのまま出力してもいいかとは思いますが、

1 2 3
4 5 6

のような形にしてみようと思います。

今ある Matrix クラスに __str__ メソッドを追加しましょう。

matrix.py
    def __str__(self) -> str:
        """行列を表示します。

        :return: str to display.
        """
        return '\n'.join(' '.join(map(str, row)) for row in self.rows)

動作確認します。

main.py
from matrix import Matrix

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

print(m)

正しく出力されました。

1 2 3
4 5 6

足し算・引き算

行列の足し算・引き算は、以下のように行番号と列番号が一致する要素同士を計算します。
そのため、行数と列数のどちらかが異なると計算できません。

\begin{pmatrix}
\color{red}{1} & \color{blue}{2} \\
\color{green}{3} & \color{orange}{4}
\end{pmatrix}
+
\begin{pmatrix}
\color{red}{5} & \color{blue}{6} \\
\color{green}{7} & \color{orange}{8}
\end{pmatrix}
=
\begin{pmatrix}
\color{red}{1 + 5} & \color{blue}{2 + 6} \\
\color{green}{3 + 7} & \color{orange}{4 + 8}
\end{pmatrix}
=
\begin{pmatrix}
\color{red}{6} & \color{blue}{8} \\
\color{green}{10} & \color{orange}{12}
\end{pmatrix}
\begin{pmatrix}
\color{red}{1} & \color{blue}{2} \\
\color{green}{3} & \color{orange}{4}
\end{pmatrix}
-
\begin{pmatrix}
\color{red}{5} & \color{blue}{6} \\
\color{green}{7} & \color{orange}{8}
\end{pmatrix}
=
\begin{pmatrix}
\color{red}{1 - 5} & \color{blue}{2 - 6} \\
\color{green}{3 - 7} & \color{orange}{4 - 8}
\end{pmatrix}
=
\begin{pmatrix}
\color{red}{-4} & \color{blue}{-4} \\
\color{green}{-4} & \color{orange}{-4}
\end{pmatrix}

配列においても番号が一致する要素同士を計算すればよいことになります。
行数と列数のどちらかが異なる場合には例外を返すようにします。
足し算は __add__ メソッド、引き算は __sub__ メソッドを実装します。

matrix.py
    def __validate_size(self, other: 'Matrix') -> None:
        """配列のサイズが一致しているかどうか調べます。

        :param other:
        :raises ValueError if the counts of row or col of two matrices are different.
        """
        if self.count_row != other.count_row:
            raise ValueError('row count must be the same')
        if self.count_col != other.count_col:
            raise ValueError('col count must be the same')

    def __calculate_each_element(self, other: 'Matrix', operation: Callable[[float, float], float]) -> 'Matrix':
        """配列の行番号・列番号が一致する要素同士で計算をします。

        :param other:
        :param operation:
        :return: new matrix.
        """
        rows = []
        for i in range(self.count_row):
            row = []
            for j in range(self.count_col):
                row.append(operation(self.rows[i][j], other.rows[i][j]))  # 番号が一致する要素同士を計算する
            rows.append(row)

        return Matrix(rows)

    def __add__(self, other: 'Matrix') -> 'Matrix':
        """足し算「+」の動作を定義します。

        :param other:
        :return: new matrix.
        """
        self.__validate_size(other)  # 行列のサイズを調べて
        return self.__calculate_each_element(other, float.__add__)  # 各成分を数値の足し算のルールで計算する

    def __sub__(self, other: 'Matrix') -> 'Matrix':
        """引き算「-」の動作を定義します。

        :param other:
        :return: new matrix.
        """
        self.__validate_size(other)  # 行列のサイズを調べて
        return self.__calculate_each_element(other, float.__sub__)  # 各成分を数値の引き算のルールで計算する

動作確認してみましょう。

main.py
from matrix import Matrix

m1 = Matrix([
    [1, 2],
    [3, 4],
])
m2 = Matrix([
    [5, 6],
    [7, 8],
])

print(m1 + m2)
print()
print(m1 - m2)

出力結果が正しいことが確認できます。

6.0 8.0
10.0 12.0

-4.0 -4.0
-4.0 -4.0

行列同士の掛け算

掛け算には 2 種類あります。

  • 行列同士の掛け算
  • 行列と数(スカラー)の掛け算

まず、行列同士の掛け算は以下のように計算します。

\begin{pmatrix}
1 & 2 & 3 \\
4 & 5 & 6
\end{pmatrix}
\begin{pmatrix}
7 & 8 \\
9 & 10 \\
11 & 12 \\
\end{pmatrix}
=
\begin{pmatrix}
1 \times 7 + 2 \times 9 + 3 \times 11 & 1 \times 8 + 2 \times 10 + 3 \times 12 \\
4 \times 7 + 5 \times 9 + 6 \times 11 & 4 \times 8 + 5 \times 10 + 6 \times 12
\end{pmatrix}

計算方法は以下の通りです。

  • かけられる行列(側)の 1 行目とかける行列(側)の 1 列目を取り出して、各要素を掛け算したものを答えの (1, 1) 成分とします。
  • かけられる行列(側)の 1 行目とかける行列(側)の 2 列目を取り出して、各要素を掛け算したものを答えの (1, 2) 成分とします。
  • かけられる行列(側)の 2 行目とかける行列(側)の 1 列目を取り出して、各要素を掛け算したものを答えの (2, 1) 成分とします。
  • かけられる行列(側)の 2 行目とかける行列(側)の 2 列目を取り出して、各要素を掛け算したものを答えの (2, 2) 成分とします。
\begin{matrix}
\hspace{45pt} \color{green}{7} &&&&&&&&&& \color{orange}{8} \\
\hspace{45pt} \color{green}{9} &&&&&&&&&& \color{orange}{10} \\
\hspace{45pt} \color{green}{11} &&&&&&&&&& \color{orange}{12} \\
\end{matrix}
\\
\begin{matrix}
\color{red}{1} & \color{red}{2} & \color{red}{3} && \\
\color{blue}{4} & \color{blue}{5} & \color{blue}{6} &&
\end{matrix}
\begin{pmatrix}
\color{red}{1} \times \color{green}{7}
+ \color{red}{2} \times \color{green}{9}
+ \color{red}{3} \times \color{green}{11}
&
\color{red}{1} \times \color{orange}{8}
+ \color{red}{2} \times \color{orange}{10}
+ \color{red}{3} \times \color{orange}{12}
\\
\color{blue}{4} \times \color{green}{7}
+ \color{blue}{5} \times \color{green}{9}
+ \color{blue}{6} \times \color{green}{11}
&
\color{blue}{4} \times \color{orange}{8}
+ \color{blue}{5} \times \color{orange}{10}
+ \color{blue}{6} \times \color{orange}{12}
\end{pmatrix}

行列の掛け算では、左側の行列の列数と右側の行列の行数が一致する必要があります。
これがバリデーションの条件にもなります。

以上のことをもとに、掛け算のメソッドを実装してみます。
Python 3.5 以降では行列の掛け算向けに @ 演算子が導入され、メソッドも __matmul__ という、まとまるものがあります。
今回は __matmul__ メソッドを実装します。

matrix.py
    def __matmul__(self, other: 'Matrix') -> 'Matrix':
        """行列同士の掛け算を定義します。

        :param other:
        :return: new matrix.
        """
        if self.count_col != other.count_row:
            raise ValueError('the col count in the first matrix must be equal to the row count in the second matrix')

        rows = []
        for i in range(self.count_row):
            row = []
            for j in range(other.count_col):
                col = Matrix.__calculate_each_element(
                    Matrix([self.rows[i]]),  # 左側の行列の i 行目と
                    Matrix([[other.rows[k][j] for k in range(other.count_row)]]),  # 右側の行列の j 列目で
                    float.__mul__,  # 各成分を掛け算したものを
                )
                row.append(sum(col.rows[0]))  # 合わせる
            rows.append(row)

        return Matrix(rows)

動作を確認してみましょう。

main.py
from matrix import Matrix

m1 = Matrix([
    [1, 2, 3],
    [4, 5, 6],
])
m2 = Matrix([
    [7, 8],
    [9, 10],
    [11, 12]
])

print(m1 @ m2)

確認できました。

58.0 64.0
139.0 154.0

行列と数(スカラー)の掛け算

行列と数(スカラー)の掛け算は、行列の各成分に与えられた数を掛ければ完成です。

ひとまず、行列に数(スカラー)を掛ける演算を __mul__ で定義します。

matrix.py
    def __mul__(self, other: float) -> 'Matrix':
        """行列にスカラーを掛ける演算を定義します。

        :param other:
        :return: new matrix.
        """
        if type(other) == Matrix:
            raise TypeError('matrix multiplication via * is not supported (use @ instead)')

        scalar = [[other for _ in range(self.count_col)] for _ in range(self.count_row)]
        return self.__calculate_each_element(Matrix(scalar), float.__mul__)

この時点で動作確認します。

main.py
from matrix import Matrix

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

print(m * 2)

どうやら正しそうです。

2.0 4.0 6.0
8.0 10.0 12.0

近年、掛け算の順番も話題になっていますから、数(スカラー)に行列を掛ける演算も確かめておきましょう。

main.py
print(2 * m)

エラーとなりました。

Traceback (most recent call last):
  File "C:\xxxxxxx\main.py", line 9, in <module>
    print(2 * m)
TypeError: unsupported operand type(s) for *: 'int' and 'Matrix'

行列に何かを掛けるときは、 Matrix クラスに定義した __mul__ メソッドの動きとなるのですが、今回のように数に行列を掛けるときは、 float や int の __mul__ メソッドが呼び出されてしまい、エラーになると考えられます。

ならば、行列が掛けられても大丈夫な float や int を用意することにします。
Scalar クラスを簡易的に作り、その中で __mul__ メソッドを定義します。
行列と数の掛け算に関しては順番を入れ替えても結果は同じです。

matrix.py
class Scalar(float):
    """Scalar クラス。

    行列との掛け算を定義するために用意します。

    :ivar value: 値
    """

    def __init__(self, v: float) -> None:
        self.value = float(v)  # 整数が来た場合も内部的に変換しておく

    def __mul__(self, other):
        if type(other) == Matrix:
            return Matrix.__mul__(other, self.value)
        return super().__mul__(other)

そして、行列クラスの __mul__ メソッドも合わせて修正します。

matrix.py
    def __mul__(self, other: 'Scalar') -> 'Matrix':
        """行列にスカラーを掛ける演算を定義します。

        :param other:
        :return: new matrix.
        """
        if type(other) == Matrix:
            raise TypeError('matrix multiplication via * is not supported (use @ instead)')

        scalar = [[other.value for _ in range(self.count_col)] for _ in range(self.count_row)]
        return self.__calculate_each_element(Matrix(scalar), float.__mul__)

改めて動作確認をします。

main.py
from matrix import Matrix, Scalar

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

print(m * Scalar(2))
print()
print(Scalar(2) * m)

正常に計算され、結果も同じになりました。

2.0 4.0 6.0
8.0 10.0 12.0

2.0 4.0 6.0
8.0 10.0 12.0

2022/05/16 追記

@shiracamus さまより こちらの記事 をご共有いただきました。

int や float に行列をかけるときに int/float の __mul__ が呼び出されてエラーとなる場合、行列クラス側に __rmul__ メソッドを実装していればそれが呼び出されるというものです。

以下の例で 2 * m を実行するとき、内部的に int.__mul__(2, m) が呼び出されますが、これはサポートされていません。
しかし、行列クラスに __rmul__ が実装されていれば Matrix.__rmul__(m, 2) が呼び出されるという仕組みです。

main.py
from matrix import Matrix

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

print(2 * m)

__rmul__ を以下のように実装すると Scalar クラスなしで 2 * m が動くようにできます。

matrix.py
    def __rmul__(self, other: float) -> 'Matrix':
        return self.__mul__(other)

割り算

「行列で割る」という操作は厳密には定義されていません。
しかし、割るのに相当することを掛け算で表現することは可能です。

実数の場合、0 でない a という数に対して、掛けて 1 にするための数 1/a (逆数) というものが存在します。

行列における逆数のような概念として「逆行列」があります。
逆行列は存在する場合、元の行列と掛けると単位行列(掛けても元の行列を変えない行列)になります。

なので、「行列で割る」という操作を「逆行列を掛ける」操作に置き換えて考えます。
逆行列が存在しない場合はエラーを出すことにします(ここでは ZeroDivisionError を採用)。

「逆行列」を求めるにあたり、先に「行列式」を求めます。

求め方については、具体的な説明は省きますが、 こちら が参考になるかと思います。

この先は、正方形の形の行列(正方行列)に限って考えることにします。
バリデーションのルールとして忘れずに実装しましょう。

行列式を __abs__ 関数(絶対値を求める関数)として実装した結果がこちらです。

matrix.py
    def __abs__(self) -> float:
        """行列式を求める演算を定義します。

        :return:
        """
        if self.count_row != self.count_col:
            raise AttributeError('determinant supports only square matrix')

        if self.count_row == 1:
            return self.rows[0][0]
        return sum(
            (-1) ** i * self.rows[i][0] * abs(Matrix([
                [col for _j, col in enumerate(row) if _j != 0]
                for _i, row in enumerate(self.rows) if _i != i
            ]))
            for i in range(self.count_row)
        )

これを利用して、逆行列を掛ける __truediv__ (/ 演算子) を実装します。

matrix.py
    def __get_inverse_matrix(self) -> 'Matrix':
        """逆行列を取得します。

        :return:
        """
        if self.count_row != self.count_col:
            raise AttributeError('determinant supports only square matrix')
        self.__validate_size(self)

        det = self.__abs__()
        if det == 0:
            raise ZeroDivisionError('determinant is 0')
        return Matrix([
            [
                (-1) ** (i + j) * abs(Matrix([
                    # 転置するのでインデックスは逆にする
                    [col for _i, col in enumerate(row) if _i != i]
                    for _j, row in enumerate(self.rows) if _j != j
                ]))
                for j in range(self.count_col)
            ]
            for i in range(self.count_row)
        ]) / Scalar(det)

    def __truediv__(self, other: Union['Scalar', 'Matrix']) -> 'Matrix':
        """行列の割り算を定義します。

        逆行列を掛けることに相当します。

        :param other:
        :return:
        """
        t = type(other)

        if t == Scalar:
            other: Scalar
            return self.__calculate_each_element(
                Matrix([[other.value for _ in range(self.count_col)] for row in range(self.count_row)]),
                float.__truediv__,
            )

        if t == Matrix:
            other: Matrix
            return self @ other.__get_inverse_matrix()

        raise TypeError('matrix cannot be divided the given object')

実行例

main.py
from matrix import Matrix, Scalar

m1 = Matrix([
    [1, 0, 0],
    [0, 1, 0],
    [0, 0, 1],
])
m2 = Matrix([
    [1, 1, -1],
    [-2, 0, 1],
    [0, 2, 1],
])

print(m1 / m2)
-0.5 -0.75 0.25
0.5 0.25 0.25
-1.0 -0.5 0.5

べき乗

最後にべき乗のメソッド __pow__ を実装します。

__pow__ メソッドの定義には引数が 2 つありますが、 modulo は余りの計算に用いるパラメータなので、今回は使用しません。
何かパラメータが渡された場合は ValueError としておきます。

def __pow__(self, power, modulo=None):

power に渡される引数は Scalar クラスのインスタンスとし、それ以外のものが渡された場合は TypeError を返すことにします。
また、整数でない値が指定された場合は ValueError を返しておきます。
Scalar が渡された場合の動きは以下のようにします:

  • 0 の場合: 単位行列を返す
  • 1 の場合: もとの行列そのものを返す
  • 2 以上の場合: もとの行列を power 回掛けたものを返す
  • 負の場合: 逆行列を power 回掛けたものを返す

2 以上で正方行列でない場合はエラーとしますが、これは行列同士の掛け算でのエラーに吸収させます。

これに従って実装した結果が以下のコードです。

matrix.py
    def __pow__(self, power: 'Scalar', modulo=None) -> 'Matrix':
        """行列のべき乗を定義します。

        :param power:
        :param modulo: 使用しません。
        :return:
        """
        if modulo is not None:
            raise ValueError('modulo is not supported')
        if type(power) != Scalar:
            raise TypeError('power supports Scalar only')
        if not power.value.is_integer():
            raise ValueError('power supports only integer')

        if power.value == 0:
            if self.count_row != self.count_col:
                raise ValueError('power 0 supports only square matrix')
            # 単位行列
            return Matrix([
                [1 if j == i else 0 for j in range(self.count_col)]
                for i in range(self.count_row)
            ])
        if power.value == 1:
            return self
        if power.value >= 2:
            return self.__pow__(Scalar(power.value - 1)) @ self
        if power.value < 0:
            return self.__get_inverse_matrix().__pow__(Scalar(-power.value))

        raise Exception('unexpected error')

実行例

main.py
from matrix import Matrix, Scalar

m1 = Matrix([
    [1, 0, 0],
    [0, 1, 0],
    [0, 0, 1],
])
m2 = Matrix([
    [1, 1, -1],
    [-2, 0, 1],
    [0, 2, 1],
])

print(m1 ** Scalar(10))
print()
print(m2 ** Scalar(2))
print()
print(m2 ** Scalar(-2))
1.0 0.0 0.0
0.0 1.0 0.0
0.0 0.0 1.0

-1.0 -1.0 -1.0
-2.0 0.0 3.0
-4.0 2.0 3.0

-0.375 0.0625 -0.1875
-0.375 -0.4375 0.3125
-0.25 0.375 -0.125

まとめ

今回は行列に関する演算を、マジックメソッドで実装してきましたが、ぶっちゃけ、普通のメソッドでも実装できることは確かです。
しかし、今回のようにマジックメソッドを行列に対応させることで、 Python を知らない方にもわかりやすく、そして直感的に演算を記述することが可能になります。
数学の分野に限らずいろいろ使い道はあると思っていますので、みなさま是非お試しください。

なお、本記事で紹介しているコードは必ずしも良いコードではありませんので、良い子のみなさんはマネをしないようにお願いします。

1
1
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1