Edited at
ChainerDay 15

chainer.links.ConvolutionNDを使ってみる

More than 1 year has passed since last update.

Chainer には、chainer.links.ConvolutionNDというクラスがあります。Convolution2Dと違い、任意の次元の畳み込みができるクラスです。

http://docs.chainer.org/en/stable/reference/generated/chainer.links.ConvolutionND.html

これは、Chainer独特の機能のようです。

あまり詳しくありませんが、、TensorFlowの場合tf.layers.1D/2D/3D、PyTorchの場合torch.nn.functional.conv1d/2d/3d、Kerasの場合keras.layers.convolutional.Conv1D/2D/3Dがあります。が、どれも、次元を固定していて、任意の次元の畳み込みができるものではありません。

というわけで、これを使って簡単に、任意の次元のConvolutionができる、と思うのですが、ConvolutionNDを使ったサンプルはあまり見つかりません。

このぐらいでしょうか。。

そもそも、2次元以外のConvolutionを何に使うのか?という話ですが、概ね、

という感じのようです。

というわけで、任意の次元の畳み込みができでも、1/2/3次元以外は特に有力な使い道は無いようですが、、ともかくConvolutionNDを使って何か書いてみたいと思います。

ここでは、動画の分類器を作ってみます。

動画の3次元CNNは、FacebookのC3Dが有名です。

https://github.com/facebook/C3D

これをChainerで実装しようと思っていたのですが、Advent Calender の日まで時間が取れず、、代わりにChainerのサンプルにあるVGG16を適等に3D化してみることにしました。

2017/12/25追記:

C3Dを移植してみました。

https://qiita.com/ikeyasu/items/79f88afffaaa0da19ea9

以下に、修正したサンプルを置きました。

https://github.com/ikeyasu/chainer-convolutionnd-sample

ファイルをコピーしたので、修正点がわかりにくくて申し訳ないですが、、models/VGG.pyVGG3Dを追加、動画ファイルを扱うためにdatasets/UCF11.pyを追加して、train_cifar.pyを修正しただけです。

もともと以下のようなBlockが定義されているのを


models/VGG.py


class Block(chainer.Chain):

def __init__(self, out_channels, ksize, pad=1):
super(Block, self).__init__()
with self.init_scope():
self.conv = L.Convolution2D(None, out_channels, ksize, pad=pad,
nobias=True)
self.bn = L.BatchNormalization(out_channels)

def __call__(self, x):
h = self.conv(x)
h = self.bn(h)
return F.relu(h)


以下の通り、ConvolutionNDを使うように変えました。第一引数のndim=3で3次元のConvolutionを指定しています。


models/VGG.py

class Block3D(chainer.Chain):

def __init__(self, in_channels, out_channels, ksize, pad=1):
super(Block3D, self).__init__()
with self.init_scope():
self.conv = L.ConvolutionND(ndim=3, in_channels=in_channels, out_channels=out_channels, ksize=ksize, pad=pad,
nobias=True)
self.bn = L.BatchNormalization(out_channels)

def __call__(self, x):
h = self.conv(x)
h = self.bn(h)
return F.relu(h)


このBlock3Dを使って、VGG3Dを作ります。__call__メソッドに、max_poolingがありますが、こちらもF.max_pooling_2dF.max_pooling_ndに変更しています。


models/VGG.py

class VGG3D(chainer.Chain):

def __init__(self, class_labels=11):
super(VGG3D, self).__init__()
with self.init_scope():
self.block1_1 = Block3D(3, 64, 3)
self.block1_2 = Block3D(64, 64, 3)
self.block2_1 = Block3D(64, 128, 3)
self.block2_2 = Block3D(128, 128, 3)
self.block3_1 = Block3D(128, 256, 3)
self.block3_2 = Block3D(256, 256, 3)
self.block3_3 = Block3D(256, 256, 3)
self.block4_1 = Block3D(256, 512, 3)
self.block4_2 = Block3D(512, 512, 3)
self.block4_3 = Block3D(512, 512, 3)
self.block5_1 = Block3D(512, 512, 3)
self.block5_2 = Block3D(512, 512, 3)
self.block5_3 = Block3D(512, 512, 3)
self.fc1 = L.Linear(None, 512, nobias=True)
self.bn_fc1 = L.BatchNormalization(512)
self.fc2 = L.Linear(None, class_labels, nobias=True)

def __call__(self, x):
# 64 channel blocks:
h = self.block1_1(x)
h = F.dropout(h, ratio=0.3)
h = self.block1_2(h)
h = F.max_pooling_nd(h, ksize=2, stride=2)

# 128 channel blocks:
h = self.block2_1(h)
h = F.dropout(h, ratio=0.4)
h = self.block2_2(h)
h = F.max_pooling_nd(h, ksize=2, stride=2)

# 256 channel blocks:
h = self.block3_1(h)
h = F.dropout(h, ratio=0.4)
h = self.block3_2(h)
h = F.dropout(h, ratio=0.4)
h = self.block3_3(h)
h = F.max_pooling_nd(h, ksize=2, stride=2)

# 512 channel blocks:
h = self.block4_1(h)
h = F.dropout(h, ratio=0.4)
h = self.block4_2(h)
h = F.dropout(h, ratio=0.4)
h = self.block4_3(h)
h = F.max_pooling_nd(h, ksize=2, stride=2)

# 512 channel blocks:
h = self.block5_1(h)
h = F.dropout(h, ratio=0.4)
h = self.block5_2(h)
h = F.dropout(h, ratio=0.4)
h = self.block5_3(h)
h = F.max_pooling_nd(h, ksize=2, stride=2)

h = F.dropout(h, ratio=0.5)
h = self.fc1(h)
h = self.bn_fc1(h)
h = F.relu(h)
h = F.dropout(h, ratio=0.5)
return self.fc2(h)


基本的にこれだけで動きました。

が、ここまでは簡単なのは当たり前でして、データをどうやって用意するか、扱うかが少々骨です。

ここでは、UCF11という小規模な動画データセットを使いました。

UCF11

http://crcv.ucf.edu/data/UCF_YouTube_Action.php

このページにある、 http://crcv.ucf.edu/data/UCF11_updated_mpg.rar をダウンロードして展開します。

$ wget http://crcv.ucf.edu/data/UCF11_updated_mpg.rar

$ mkdir videos
$ pushd videos
$ unar e UCF11_updated_mpg.rar
$ popd

この動画を画像に変換します。ここでは、ffmpegを使います。


tools/video_to_image.sh

#!/bin/bash

FILE=$1
mkdir -p ${FILE%.*}
ffmpeg -i "$FILE" -vf fps=5 "${FILE%.*}"/%05d.jpg

上記のスクリプトを使って、以下のように変換します。以下では、並列に変換できるよう、GNU Parallelを使っています。

$ ls videos/*.mpg | parallel --no-notice -j8 ./tools/video_to_images.sh  {}

次に、このままでは画像が大きいのでCropして、リサイズします。今回は試しに作るだけですので、cifar10と同じ、32x32の画像にしました。


tools/resize.py

from PIL import Image

import numpy as np
import argparse

def main(path, out_path):
img = Image.open(path, 'r')
h, w = img.size
cw = h // 2
cx = int((w - cw) / 4)
cy = int((h - cw) / 4)
clp = img.crop((cx, cy, w - cx, h - cy))
resized = clp.resize((32, 32))
resized.save(out_path, 'JPEG', quality=100, optimize=True)

if __name__ == "__main__":
parser = argparse.ArgumentParser(
description='clipping and resize')
parser.add_argument('--input', '-i', default='input.jpg',
help='input file')
parser.add_argument('--output', '-o', default='output.jpg',
help='output file')
args = parser.parse_args()
main(args.input, args.output)


$ ls videos/*/*.jpg | parallel -j20 'python tools/resize.py -i {} -o `echo {} | sed s/videos/images/`'

上記で、以下のようにデータが用意されています。

.

├── images
│   ├── v_biking_01_01
│   │   ├── 00001.jpg
│   │   ├── 00002.jpg
│   │ ├── ...
│ │   └── 00026.jpg
│   ├── ....

└── videos
├── v_biking_01_01
│   ├── 00001.jpg
│   ├── 00002.jpg
│   ├── ....
│   └── 00026.jpg
   ├── ....

これを扱うために、Datasetのクラスを作ります。

import chainer

import os
import re
import numpy as np

def get_dir_list(path):
return [os.path.join(path, i) for i in os.listdir(path) if os.path.isdir(os.path.join(path, i))]

LABEL_PATTERN = re.compile(r"v_([a-zA-Z_]+)_[0-9]")

def get_label(path):
dir = os.path.split(path)[-1]
matched = LABEL_PATTERN.match(dir)
return matched.group(1)

class UCF11Dataset(chainer.dataset.DatasetMixin):
"""
example:
videos/v_biking_01_01/00001.jpg
videos/v_biking_01_01/00002.jpg
"""

def __init__(self, path):
self.dir_list = get_dir_list(path)
labels = [get_label(d) for d in self.dir_list]
self.labels = list(set(labels))

def __len__(self):
return len(self.dir_list)

def get_example(self, i):
base = chainer.datasets.ImageDataset(os.listdir(self.dir_list[i]), root=self.dir_list[i])
label = get_label(self.dir_list[i])

frames = 6
images = np.array([base[i] for i in range(frames)]).transpose(1, 0, 2, 3)

# TODO: mean
# image -= self.mean[:, top:bottom, left:right]
images *= (1.0 / 255.0) # Scale to [0, 1]
return images, self.labels.index(label)

ここで注意すべきは、get_exampleで返している画像の行列です。Convolution2Dで使う場合、(channel, height, width)の形の行列を返すように書くと思います。(例:chainer/examples/imagenet

今回、ConvlutionNDを使って3次元CNNをするので、行列の形は、(channel, frame, height, width)になります。

上記を使って実行するように、train_cifar.pyを修正します。

一応、accuracyが向上していくのが確認できました。(特に工夫していないので、精度はあまり出ていません)

$ python train_cifar.py -g 0

GPU: 0
# Minibatch-size: 64
# epoch: 300

Using CIFAR100 dataset.
/home/ikeyasu/anaconda3/envs/chainer3/lib/python3.6/site-packages/chainer/utils/experimental.py:104: FutureWarning: chainer.functions.pooling.MaxPoolingND is experimental. The interface can change in the future.
FutureWarning)
epoch main/loss validation/main/loss main/accuracy validation/main/accuracy elapsed_time
1 2.87406 27.3399 0.099375 0.122659 12.3509
2 2.99936 2.39463 0.1075 0.151621 24.2535
3 2.65441 2.47364 0.165 0.18714 36.1351
4 2.49547 2.44088 0.14625 0.19339 48.0406
5 2.41286 2.32186 0.155 0.163962 59.9657
...

っとここまでかいて、、"chainer.functions.pooling.MaxPoolingND is experimental"と出ていることに気付きました。ConvolutionNDは結構前から入っていますが、max_pooling_ndの方は、Experimentalなんですね。。

というわけで、中途半端なサンプルですが、VGGのサンプルを修正するだけで、一応、3次元CNNらしき事はできました。

今後の展望としては、C3Dのネットワーク構造を実装したり、C3DのpretrainedモデルをFine-tuningしたいと思っています。