PyTorchで行列(テンソル)積としてConv2dを使う
PyTorchではmatmulの挙動が特殊なので、思った通りにテンソル積が取れないことがあります。この記事では、基本的な畳み込み演算である「Conv2D」を使い、Numpyのドット積相当の演算を行うという方法を解説します。
目次
はじめに
PyTorchの変態コーディング技術です。多分。
画像のテンソルのチャンネル成分に対して処理をかけたいというのはときどき遭遇します。Keras(TensorFlowバックエンド)だとチャンネルが最後にあるので、4階テンソルに対して2×2行列をK.dotで掛けて終わりですが、PyTorchだとチャンネルが最後ではない(NCHWなので2番目)ので、かなり面倒です。
先に結論を言うと、この手のテンソル積を取りたいときは、torch.matmulなのですが、PyTorchのmatmulの挙動が相当特殊なので、自分では再現できませんでした。Torchのテンソルをpermute関数使って軸を逐一入れ替えて積を取るとかは一応できるのですが、それだとパフォーマンス面でちょっとどうなのかな?と思うわけです。
PyTorchの場合はいい方法見つけました。1×1畳み込みの計算が同じなのでConv2Dを使ってテンソル積を計算してしまうという方法です。Numpy、Keras、PyTorchの順に具体例で説明していきます。
具体例:色空間変換
画像の色空間を考えます。普段ピクセルのRGB値というときはRGB色空間ですが、輝度や色差といった情報を取り出したい場合は別の色空間に変換するのが便利です。ピクセル値は24ビットカラーでは0~255の整数ですが、RGB色空間では0~1の小数で考えます。一方で、YCrCb色空間は、Y(輝度)は0~1、CrCb(色差)は-0.5~0.5の小数で定義されます。
RGB色空間→YCrCb色空間への変換、あるいはその逆変換は行列積で計算できます。Wikipediaによれば
$$ \begin{bmatrix}Y\\Cr\\Cb\end{bmatrix} = \begin{bmatrix}0.299 & 0.587 & 0.114 \\ 0.5 & -0.418688 & -0.081312 \\ -0.168736 & -0.331264 & 0.5 \end{bmatrix} \begin{bmatrix}R\\G\\B\end{bmatrix}$$
$$\begin{bmatrix}R\\G\\B\end{bmatrix} = \begin{bmatrix}1 & 1.402 & 0 \\ 1 & -0.714136 & -0.344136 \\ 1 & 0 & 1.772\end{bmatrix}\begin{bmatrix}Y\\Cr\\Cb\end{bmatrix}$$
で表されます。本当にこんなんで正しいの?ということで、RGB→YCrCb→RGB、YCrCb→RGB→YCrCbの変換をしても元に戻る(行列積が単位行列になる)のを確認します。
import numpy as np
RGB2YCrCb = np.array([[0.299, 0.587, 0.114],
[0.5, -0.418688, -0.081312],
[-0.168736, -0.331264, 0.5]], np.float32)
YCrCb2RGB = np.array([[1, 1.402, 0],
[1, -0.714136, -0.344136],
[1, 0, 1.772]], np.float32)
print(np.dot(RGB2YCrCb, YCrCb2RGB))
print(np.dot(YCrCb2RGB, RGB2YCrCb))
結果は以下のようになります。
[[ 1.0000000e+00 1.3312457e-07 1.6485286e-07]
[ 0.0000000e+00 1.0000001e+00 7.5418632e-07]
[ 0.0000000e+00 -3.1320928e-07 9.9999982e-01]]
[[ 1.0000000e+00 -5.4178543e-07 5.7903833e-07]
[ 1.2339633e-07 1.0000001e+00 -1.7881393e-07]
[-1.8215839e-07 2.3836556e-07 1.0000000e+00]]
どちらも単位行列となりました(若干誤差はある)。確かに正しそうです。
Numpyで色空間変換
さて、色空間変換を画像に適用してみましょう。
こちらの記事からテスト用のカラーグラデーションを持ってきて使います。これを「color_bar.png」とします。
from PIL import Image
import numpy as np
RGB2YCrCb = np.array([[0.299, 0.587, 0.114],
[0.5, -0.418688, -0.081312],
[-0.168736, -0.331264, 0.5]], np.float32)
YCrCb2RGB = np.array([[1, 1.402, 0],
[1, -0.714136, -0.344136],
[1, 0, 1.772]], np.float32)
def numpy_version():
with Image.open("color_bar.png") as img:
rgb_array = np.asarray(img, np.float32) / 255.0
print("RGB original")
print(rgb_array.shape)
check_range(rgb_array)
print()
ycrcb_array = np.dot(rgb_array, RGB2YCrCb.T)
print("Coverted to YCrCb")
print(ycrcb_array.shape)
check_range(ycrcb_array)
print()
recon_array = np.dot(ycrcb_array, YCrCb2RGB.T)
print("Reconstrunction RGB")
print(recon_array.shape)
check_range(recon_array)
def check_range(img_array):
x =img_array.reshape(-1, img_array.shape[2])
mins = np.min(x, axis=0)
maxs = np.max(x, axis=0)
print("mins :", mins)
print("maxs :", maxs)
if __name__ == "__main__":
numpy_version()
先に全体のコードを貼ります。やっているのことは、画像を読み込む→RGB色空間のテンソルとして保存→YCrCb色空間に変換→RGB色空間に戻すということです。各処理の終わりにcheck_rangeという値域の確認用の関数を差し込んいます。これでRGB色空間は全て0~1、YCrCb色空間はYが0~1/CrCbが-0.5~0.5の範囲にあるということを確認します。
結果は次の通りになります。
RGB original
(800, 1280, 3)
mins : [0. 0. 0.]
maxs : [1. 1. 1.]
Coverted to YCrCb
(800, 1280, 3)
mins : [ 4.4705885e-04 -5.0000000e-01 -5.0000000e-01]
maxs : [1. 0.5 0.5]
Reconstrunction RGB
(800, 1280, 3)
mins : [-5.3644180e-07 -1.7881393e-07 -1.7881393e-07]
maxs : [1.0000006 1.0000002 1.0000002]
うまく行っています。YCrCbにすると2,3つ目の最小が-0.5, 最大が0.5になるのに対して、RGBに再度戻すと全て最小が0、最大が1に統一されていますね。
ここからがポイントなのですが、全ての変換計算はnp.dotというドット積の関数で計算しています。3階テンソルと2階テンソルの掛け算ですが、(800, 1200, 3)の最後の3に合わせて(3, 3)を掛けるという計算ですね。もし掛ける側が(3, 4)になったら、(800, 1200, 4)という出力になりますね。
ただし、変換式の定義がピクセル値を右からかけるのに対して、np.dotではピクセル値を左からかけているので、変換行列の転置操作が必要になります。
Kerasの場合
Kerasの場合はNumpyとほとんど変わりません。実際の画像処理では画像を複数まとめてミニバッチとして扱うことが多いので、np.expand_dimsで4階テンソルとして計算します。バッチサイズが1のミニバッチですね。
import keras.backend as K
import numpy as np
from PIL import Image
RGB2YCrCb = np.array([[0.299, 0.587, 0.114],
[0.5, -0.418688, -0.081312],
[-0.168736, -0.331264, 0.5]], np.float32)
YCrCb2RGB = np.array([[1, 1.402, 0],
[1, -0.714136, -0.344136],
[1, 0, 1.772]], np.float32)
def keras_version():
with Image.open("color_bar.png") as img:
rgb_array = np.asarray(img, np.float32) / 255.0
rgb_tensor = K.variable(np.expand_dims(rgb_array, axis=0))
print("RGB original")
print(rgb_tensor.shape)
check_range(rgb_tensor)
print()
ycrcb_tensor = K.dot(rgb_tensor, K.variable(RGB2YCrCb.T))
print("Coverted to YCrCb")
print(ycrcb_tensor.shape)
check_range(ycrcb_tensor)
print()
recon_tensor = K.dot(ycrcb_tensor, K.variable(YCrCb2RGB.T))
print("Reconstrunction RGB")
print(recon_tensor.shape)
check_range(recon_tensor)
def check_range(img_tensor):
img_array = K.eval(img_tensor)
x =img_array.reshape(-1, img_array.shape[3])
mins = np.min(x, axis=0)
maxs = np.max(x, axis=0)
print("mins :", mins)
print("maxs :", maxs)
if __name__ == "__main__":
keras_version()
np.dotをK.dotに置き換えればいいだけです。掛けられる側が3階テンソルだろうが、4階テンソルだろうが大丈夫です。以下のようになります。
RGB original
(1, 800, 1280, 3)
mins : [0. 0. 0.]
maxs : [1. 1. 1.]
Coverted to YCrCb
(1, 800, 1280, 3)
mins : [ 4.4705885e-04 -5.0000000e-01 -5.0000000e-01]
maxs : [1. 0.5 0.5]
Reconstrunction RGB
(1, 800, 1280, 3)
mins : [-5.3644180e-07 -1.7881393e-07 -1.7881393e-07]
maxs : [1.0000006 1.0000002 1.0000002]
それぞれの色空間の定義に則っているのが確認できます。
1×1畳み込み
NumpyやKerasの場合、チャンネルの軸が最後にあったのでそのままどんとテンソル積をとってあげれば大丈夫でした。しかし、PyTorchの場合はchannels_firstなので、そのまま積を取るというわけにはいかなくなります(einsumの関数を使えば書けるだろうけど自分はやりたくない)。
ここで畳み込みの計算定義(Conv2D)を思い出しましょう。自分が書いた記事からの図ですが、
これは3×3カーネルの場合です。しかし、カーネルが1×1だったらどうでしょう? 行列単位で見ればドット積と変わらなくなりますよね。つまり、1×1畳み込みはテンソル積の計算関数として使えるということなのです。
PyTorchでの1×1畳み込みによるテンソル計算
全体のコードを貼ります。
import torch
import torch.nn.functional as F
import numpy as np
from PIL import Image
RGB2YCrCb = np.array([[0.299, 0.587, 0.114],
[0.5, -0.418688, -0.081312],
[-0.168736, -0.331264, 0.5]], np.float32)
YCrCb2RGB = np.array([[1, 1.402, 0],
[1, -0.714136, -0.344136],
[1, 0, 1.772]], np.float32)
def pytorch_version():
with Image.open("color_bar.png") as img:
rgb_array = np.asarray(img, np.float32) / 255.0
# PyTorchのためにNHWCをNCHWとする
rgb_array = np.expand_dims(rgb_array, axis=0)
rgb_array = np.transpose(rgb_array, [0,3,1,2])
rgb_tensor = torch.as_tensor(rgb_array)
print("RGB original")
print(rgb_tensor.size())
check_range(rgb_tensor)
print()
# 右から掛ける場合でも転置はいらない
weight_tensor = torch.as_tensor(RGB2YCrCb.reshape(3,3,1,1))
ycrcb_tensor = F.conv2d(rgb_tensor, weight_tensor)
print("Coverted to YCrCb")
print(ycrcb_tensor.shape)
check_range(ycrcb_tensor)
print()
weight_tensor = torch.as_tensor(YCrCb2RGB.reshape(3,3,1,1))
recon_tensor = F.conv2d(ycrcb_tensor, weight_tensor)
print("Reconstrunction RGB")
print(recon_tensor.shape)
check_range(recon_tensor)
def check_range(img_tensor):
img_array = img_tensor.numpy()
img_array = np.transpose(img_array, [0,2,3,1])
x =img_array.reshape(-1, img_array.shape[3])
mins = np.min(x, axis=0)
maxs = np.max(x, axis=0)
print("mins :", mins)
print("maxs :", maxs)
if __name__ == "__main__":
pytorch_version()
ポイントは「F.conv2d(rgb_tensor, weight_tensor)」の部分です。
引数の順番はnp.dotやK.dotと同じです。つまり、畳み込みのカーネル部分にかける側のテンソルを入れればよいということです。ただし、weight_tensorは4階テンソルにします。shapeの詳細は(出力チャンネル, 入力チャンネル, カーネルサイズ1, カーネルサイズ2)なので、今回の場合は「(3,3,1,1)」とするのが正しいです。
また、np.dotやK.dotと異なるのは、変換行列の転置が不要ということです。畳み込みの内部実装でやっているのでしょう。
結果は次のようになります。
RGB original
torch.Size([1, 3, 800, 1280])
mins : [0. 0. 0.]
maxs : [1. 1. 1.]
Coverted to YCrCb
torch.Size([1, 3, 800, 1280])
mins : [ 4.4705885e-04 -5.0000000e-01 -5.0000000e-01]
maxs : [1. 0.5 0.5]
Reconstrunction RGB
torch.Size([1, 3, 800, 1280])
mins : [-5.3644180e-07 -1.7881393e-07 -1.9312506e-07]
maxs : [1.0000006 1.0000002 1.0000002]
確かにうまくいきました。これは使えそうですね。
まとめ
Conv2Dは1×1カーネルにすれば普通のテンソル積の計算もできる。PyTorchの場合、チャンネルが一番外の軸ではないのでこの方法が有効。パフォーマンス面からはいちいち軸を入れ替えてmatmul取るよりこの方法が良いのでは? ということでした。多分変態実装言われそう
Shikoan's ML Blogの中の人が運営しているサークル「じゅ~しぃ~すくりぷと」の本のご案内
技術書コーナー
「本当の実装力を身につける」ための221本ノック――
機械学習(ML)で避けて通れない数値計算ライブラリ・NumPyを、自在に活用できるようになろう。「できる」ための体系的な理解を目指します。基礎から丁寧に解説し、ディープラーニング(DL)の難しいモデルで遭遇する、NumPyの黒魔術もカバー。初心者から経験者・上級者まで楽しめる一冊です。問題を解き終わったとき、MLやDLなどの発展分野にスムーズに入っていけるでしょう。
本書の大きな特徴として、Pythonの本でありがちな「NumPyとML・DLの結合を外した」点があります。NumPyを理解するのに、MLまで理解するのは負担が大きいです。本書ではあえてこれらの内容を書いていません。行列やテンソルの理解に役立つ「従来の画像処理」をNumPyベースで深く解説・実装していきます。
しかし、問題の多くは、DLの実装で頻出の関数・処理を重点的に取り上げています。経験者なら思わず「あー」となるでしょう。関数丸暗記では自分で実装できません。「覚える関数は最小限、できる内容は無限大」の世界をぜひ体験してみてください。画像編集ソフトの処理をNumPyベースで実装する楽しさがわかるでしょう。※紙の本は電子版の特典つき
- まとめURL:https://github.com/koshian2/numpy_book
- みんなの感想:https://togetter.com/li/1641475
- A4 全176ページモノクロ / 2020年12月発行
「誰もが夢見るモザイク除去」を起点として、機械学習・ディープラーニングの基本をはじめ、GAN(敵対的生成ネットワーク)の基本や発展型、ICCV, CVPR, ECCVといった国際学会の最新論文をカバーしていく本です。
ディープラーニングの研究は発展が目覚ましく、特にGANの発展型は市販の本でほとんどカバーされていない内容です。英語の原著論文を著者がコードに落とし込み、実装を踏まえながら丁寧に解説していきます。
また、本コードは全てTensorFlow2.0(Keras)に対応し、Googleの開発した新しい機械学習向け計算デバイス・TPU(Tensor Processing Unit)をフル活用しています。Google Colaboratoryを用いた環境構築不要の演習問題もあるため、読者自ら手を動かしながら理解を深めていくことができます。
AI、機械学習、ディープラーニングの最新事情、奥深いGANの世界を知りたい方にとってぜひ手にとっていただきたい一冊となっています。持ち運びに便利な電子書籍のDLコードが付属しています。
「おもしろ同人誌バザールオンライン」で紹介されました!(14:03~) https://youtu.be/gaXkTj7T79Y?t=843
まとめURL:https://github.com/koshian2/MosaicDeeplearningBook
A4 全195ページ、カラー12ページ / 2020年3月発行
累計100万PV超の人気ブログが待望の電子化! このブログが電子書籍になって読みやすくなりました!
・1章完結のオムニバス形式
・機械学習の基本からマニアックなネタまで
・どこから読んでもOK
・何巻から読んでもOK
・短いものは2ページ、長いものは20ページ超のものも…
・通勤・通学の短い時間でもすぐ読める!
・読むのに便利な「しおり」機能つき
・全巻はA5サイズでたっぷりの「200ページオーバー」
・1冊にたっぷり30本収録。1本あたり18.3円の圧倒的コストパフォーマンス!
・文庫本感覚でお楽しみください
北海道の駅巡りコーナー
ローカル線や秘境駅、マニアックな駅に興味のある方におすすめ! 2021年に大半区間が廃線になる、北海道の日高本線の全区間・全29駅(苫小牧~様似)を記録した本です。マイカーを使わずに、公共交通機関(バス)と徒歩のみで全駅訪問を行いました。日高本線が延伸する計画のあった、襟裳岬まで様似から足を伸ばしています。代行バスと路線バスの織り成す極限の時刻表ゲームと、絶海の太平洋と馬に囲まれた日高路、日高の隠れたグルメを是非たっぷり堪能してください。A4・フルカラー・192ページのたっぷりのボリュームで、あなたも旅行気分を漫喫できること待ったなし!
見どころ:日高本線被災区間(大狩部、慶能舞川橋梁、清畠~豊郷) / 牧場に囲まれた絵笛駅 / 窓口のあっただるま駅・荻伏駅 / 汐見の戦争遺跡のトーチカ / 新冠温泉、三石温泉 / 襟裳岬
A4 全192ページフルカラー / 2020年11月発行