はじめに
AcademiXでは「つくりながら学ぶ! PyTorchによる発展ディープラーニング」を参考書とした勉強会を行っています。
その勉強会において、本書の第2章「物体検出 SSD」を読んで、疑問点や理解するのに苦労した点がいくつかあったので、その解説をまとめました。
ただし、本記事はSSDの解説記事ではないのでご注意ください🙇
あくまで本書 第2章の補助資料としてご活用ください。
また、本書のGithubが公開されているので、本書を持っていない方はそちらを参照してください。
第2章は「2_objectdetection」フォルダで実装されています。
make_datapath_list関数で登場する「%s」とは?
本書: p67
該当ファイル: 2-2-3_Dataset_DataLoader.ipynb
make_datapath_list関数の中で、以下のようなコードがあります。
# 画像ファイルとアノテーションファイルへのパスのテンプレートを作成
imgpath_template = osp.join(rootpath, 'JPEGImages', '%s.jpg')
annopath_template = osp.join(rootpath, 'Annotations', '%s.xml')
# ...数行スキップして
img_path = (imgpath_template % file_id) # 画像のパス
anno_path = (annopath_template % file_id) # アノテーションのパス
ここで、「%」を使った謎の書き方がされています。
これは「%」を使って文字列に文字列を代入する記法で、ここでは%s
にfile_id
を代入しています。
詳しい使い方はこちらの記事を参考にして下さい。
今回作成したい文字列はそこまで複雑ではないですし、単純に「+」で文字列を結合するのが一番可読性が高いと思います。
xml.etree.ElementTreeの使い方の解説
本書: p70
該当ファイル: 2-2-3_Dataset_DataLoader.ipynb
XMLファイルの構文解析をするのに、本書ではxml.etree.ElementTreeが使われています。
これは、Pythonの標準モジュールなので、
import xml.etree.ElementTree as ET
とインポートすればすぐに使えます。
ET
という略称でインポートするのが一般的です。
なぜxml.etree.ElementTreeを使うのか
では、なぜこのモジュールを使うと便利なのかを理解するために、試しにアノテーションファイルを1つ開いてみましょう。
/VOCdevkit/VOC2012/Annotations/2008_000008.xml
をテキストエディタで開くと、下のようになります。
<annotation>
<folder>VOC2012</folder>
<filename>2008_000008.jpg</filename>
<source>
<database>The VOC2008 Database</database>
<annotation>PASCAL VOC2008</annotation>
<image>flickr</image>
</source>
<size>
<width>500</width>
<height>442</height>
<depth>3</depth>
</size>
<segmented>0</segmented>
<object>
<name>horse</name>
<pose>Left</pose>
<truncated>0</truncated>
<occluded>1</occluded>
<bndbox>
<xmin>53</xmin>
<ymin>87</ymin>
<xmax>471</xmax>
<ymax>420</ymax>
</bndbox>
<difficult>0</difficult>
</object>
<object>
<name>person</name>
<pose>Unspecified</pose>
<truncated>1</truncated>
<occluded>0</occluded>
<bndbox>
<xmin>158</xmin>
<ymin>44</ymin>
<xmax>289</xmax>
<ymax>167</ymax>
</bndbox>
<difficult>0</difficult>
</object>
</annotation>
HTMLを知っている方は馴染みがあると思いますが、xmlファイルでは、1つ1つのデータをタグを使って記述します。
xmlファイルは下のような要素からなっており、先頭の<要素名>
を開始タグ、末尾の</要素名>
を終了タグといいます。
<要素名>データ</要素名>
さらに、要素の中に要素を入れる、という入れ子構造を作ることもできます。
先ほど開いた2008_000008.xml
で、入れ子構造の最も外側のタグ、すなわち最頂点にある要素はannotation
です。
そしてそれを頂点として、その子要素、さらにその子要素、、、というような入れ子構造になっていることがわかりますでしょうか?
そのような要素と要素の関係に注目して、xmlファイルから欲しいデータを簡単に抽出することができるのがxml.etree.ElementTreeモジュールです。
xmlファイルは所詮テキストデータなので、xml.etree.ElementTreeを使わないで、下のようにopen()
関数を使って開くこともできます。
with open("2008_000008.xml", "rb") as f:
lines = f.read()
print(lines)
# 出力は b'<annotation>\n\t<folder>VOC2012</folder>\n\t<filename>2008_000008.jpg</filename>\n\t<source>\n\t\t<database>The VOC2008 Database</database>\n\t\t<annotation>PASCAL VOC2008</annotation>\n\t\t<image>flickr</image>\n\t</source>\n\t<size>\n\t\t<width>500</width>\n\t\t<height>442</height>\n\t\t<depth>3</depth>\n\t</size>\n\t<segmented>0</segmented>\n\t<object>\n\t\t<name>horse</name>\n\t\t<pose>Left</pose>\n\t\t<truncated>0</truncated>\n\t\t<occluded>1</occluded>\n\t\t<bndbox>\n\t\t\t<xmin>53</xmin>\n\t\t\t<ymin>87</ymin>\n\t\t\t<xmax>471</xmax>\n\t\t\t<ymax>420</ymax>\n\t\t</bndbox>\n\t\t<difficult>0</difficult>\n\t</object>\n\t<object>\n\t\t<name>person</name>\n\t\t<pose>Unspecified</pose>\n\t\t<truncated>1</truncated>\n\t\t<occluded>0</occluded>\n\t\t<bndbox>\n\t\t\t<xmin>158</xmin>\n\t\t\t<ymin>44</ymin>\n\t\t\t<xmax>289</xmax>\n\t\t\t<ymax>167</ymax>\n\t\t</bndbox>\n\t\t<difficult>0</difficult>\n\t</object>\n</annotation>\n'
しかし、そうすると本来欲しい情報ではない開始タグや終了タグなどが混じった汚い文字列が取得されてしまいますし、要素と要素の関係などもわかりにくくなってしまいます。
そのような汚い文字列から、欲しい情報を抽出するのは非常に大変です。
ですが、xml.etree.ElementTreeモジュールを使えば、xmlファイルの構文解析を行い、要素と要素の関係を分析し、簡単に欲しい情報を抽出することができるんです。
xml.etree.ElementTreeを使ってみる
では、xml.etree.ElementTreeを使ってみましょう。
使い方はBeautiful Soupとほとんど同じです。
まず、最初に構文解析を行います。
tree = ET.parse('2008_000008.xml')
次に、tree
に対してgetroot()
メソッドを使うことで、最頂点にある要素 annotation
を取得できます。
xml = tree.getroot()
以上で下準備は完了です。
xml.etree.ElementTreeの基本として、ある要素に対してiter()
やfind()
などのメソッドを適用することで、他の要素を取得することができます。
現在、最頂点のannotation
要素をxml
という変数に入れているので、これをスタート地点として他の要素を取得していきます。
iter()
やfind()
などの使い方は下の記事を参考にしてください。
nn.Conv2dのdilationとは?
本書: p84
該当ファイル: 2-4-5_SSD_model_forward.ipynb
make_vgg関数内に次のようなコードがあります。
conv6 = nn.Conv2d(512, 1024, kernel_size=3, padding=6, dilation=6)
dilation
は「ダイレイション」と発音します。
この dilation
の役割について本書の第2章では何も説明がありませんが、第3章 p155に説明があるので、そちらをご覧ください。
詳細を知りたい方は下の記事を参考にしてください。
L2Normでは正規化した後になぜ20をかけるのか?
本書: p88
該当ファイル: 2-4-5_SSD_model_forward.ipynb
L2Norm
クラスのforward
メソッドで、L2ノルムで正規化した x
に、なぜか20という重みをかけています。
該当コードは以下の部分です。
out = weights * x
なぜ20をかけているかについて本書では全く説明がありませんが、こちらのIssueに説明が書いてありました。
著者曰く、「SSDのリポジトリにそう書いてあったから」だそうです。
これでは腑に落ちないので、20をかける理由を勉強会で議論した結果、L2ノルムで割ると値が小さくなりすぎて学習が思うように進まないため、ある程度値を大きくしたいのではないか?という結論に至りました。
これについてご意見がありましたらコメントにてお願いします。
DBoxクラスの初期値について
本書: p89
該当ファイル: 2-4-5_SSD_model_forward.ipynb
DBoxクラスの__init__
メソッドでさまざまな初期設定がされていますが、「なぜその値を設定したのか?」という値の根拠が本書に書いていませんでした。
その考察・理由が下のIssueにありましたので、ご覧ください。
非常に詳しく書かれているので、とても参考になります。
また、設定値に誤植があるのでご注意ください。
DBoxクラスのmake_dbox_listメソッドの実装コードが難しい
本書: p89
該当ファイル: 2-4-5_SSD_model_forward.ipynb
make_dbox_list
メソッドでは8732個のdboxを作成しています。
そこでは、次のようにitertoolsのproduct
が使われていますが、product
を使ったことがない私は初見では理解できませんでした。
for i, j in product(range(f), repeat=2):
これは次の2通りで書き換えることができます。
# repeatを使わない場合
for i, j in product(range(f), range(f)):
# 単純に2重ループで書く場合
for i in range(f):
for j in range(f):
単純に2重ループを書くのが一番わかりやすいですね。
つまり、ここでは、特徴量マップの1つ1つのセルを順番にたどってdboxを作成する、ということが行われているわけです。
product
の詳しい使い方については下の記事をご覧ください。
また、p90の「アスペクト比1の大きいDBox」と「その他のアスペクト比のdefbox作成」では、なぜかsqrt()
を使っています。
それについては下のIssueに説明がありました。
著者曰く、
一般にアスペクト比といういうと、長さの比率をイメージします。
ですが、参考にしている実装は”面積比率をアスペクト比と呼んでいる”(と思われる)ため、ルートを計算している(と思われます)。
だそうです。これで納得しました。
nm_suppression関数で登場するnewメソッドとは?
本書: p96
該当ファイル: 2-4-5_SSD_model_forward.ipynb
nm_suppression
関数はNon-Maximum Supression (非極大値抑制)を行う関数です。
そこでは、.new()
というメソッドを使ってひな形となる変数を作成し、その後、index_select
関数のout
引数にその変数を指定することで、変数への代入が行われています。
その該当部分を抜き出したコードが下になります。
# boxesをコピーする。後で、BBoxの被り度合いIOUの計算に使用する際のひな形として用意
tmp_x1 = boxes.new()
tmp_y1 = boxes.new()
tmp_x2 = boxes.new()
tmp_y2 = boxes.new()
tmp_w = boxes.new()
tmp_h = boxes.new()
# 数行スキップして
# -------------------
# これからkeepに格納したBBoxと被りの大きいBBoxを抽出して除去する
# -------------------
# ひとつ減らしたidxまでのBBoxを、outに指定した変数として作成する
torch.index_select(x1, 0, idx, out=tmp_x1)
torch.index_select(y1, 0, idx, out=tmp_y1)
torch.index_select(x2, 0, idx, out=tmp_x2)
torch.index_select(y2, 0, idx, out=tmp_y2)
そもそも、new()
メソッドは初めて聞いたので公式ドキュメントで検索したのですが、全く説明がありませんでした。
実はnew()
は現在は廃止されたメソッドで、PyTorchの古いバージョンのドキュメントに説明が書いてありました。
new()
メソッドでは、Tensorと同じデータ型の、空のtorch.Tensorを作成するそうです。
よって、ただ変数宣言しただけという理解でOKです。
そして、index_select
関数で使われている引数 out
は、「計算結果をoutに指定したtensorに出力するという意味」があります。
したがって、下のように、計算結果を普通に変数に代入するというコードの方がわかりやすいです。
tmp_x1 = torch.index_select(x1, 0, idx)
tmp_y1 = torch.index_select(y1, 0, idx)
tmp_x2 = torch.index_select(x2, 0, idx)
tmp_y2 = torch.index_select(y2, 0, idx)
念の為、引数 out
を使うことで何かメリットがあるのか調べてみたところ、PyTorchのdiscussionに同じ質問がありました。
それによると、大きいテンソルを扱っている場合は、out
を使うことでメモリ節約の効果があるそうです。
しかし、今回扱っているテンソルは大きいテンソルではないので、普通に代入する書き方で問題ないと思います。
SSDクラスの実装で登場するcontiguousメソッドの役割とは?
本書: p102
該当ファイル: 2-4-5_SSD_model_forward.ipynb
SSDクラスのforward
メソッドの実装で、次のようなコードがありました。
for (x, l, c) in zip(sources, self.loc, self.conf):
# Permuteは要素の順番を入れ替え
loc.append(l(x).permute(0, 2, 3, 1).contiguous())
conf.append(c(x).permute(0, 2, 3, 1).contiguous())
# 数行スキップして
# (注釈)
# torch.contiguous()はメモリ上で要素を連続的に配置し直す命令です。
# あとでview関数を使用します。
# このviewを行うためには、対象の変数がメモリ上で連続配置されている必要があります。
# さらにlocとconfの形を変形
# locのサイズは、torch.Size([batch_num, 34928])
# confのサイズはtorch.Size([batch_num, 183372])になる
loc = torch.cat([o.view(o.size(0), -1) for o in loc], 1)
conf = torch.cat([o.view(o.size(0), -1) for o in conf], 1)
一応コメントアウトに説明が書いてありますが、 これだけでは腑に落ちなかったので、contiguous()
の役割を調べました。
下の記事が一番わかりやすかったです。
まとめ
以上が解説になります。
他にも第2章の内容で、解説した方がいいと思った点があれば、加筆していこうと思います。
(追記)
著者の小川さんにこの記事をいいねされました。嬉しいです。