はじめに
この記事では、機械学習プロジェクトのデータセットの管理と Chainer のDataset クラスを自作する際のTipsを紹介します。データセットの管理については、 Chainer に限らず使える Tips だと思います。あくまで自分が良いと思った開発フローや設計におけるTipsなので、より良い方法がございましたら、コメントしていただけると幸いです。
今回は、以下のブログ記事で紹介されている、CNNとLSTMを用いたファッションコーディネートモデルのプロジェクトを題材にハンズオン形式で、データセットまわりの開発フローや設計のTipsを紹介していきます。
https://techblog.zozo.com/entry/outfit_generator
対象者・前提知識
学部4年生など、Chainer を用いて、研究などの機械学習プロジェクトを始められる方が対象です。MNISTのチュートリアルは動かせたけど、論文の再実装や自分の作りたいものを作るためには、どう着手すればよいかわからない、といった初級者から中級者を目指す方も対象です。記事の内容が少なくはないので、実際に自分のプロジェクトを進めていく中で、ぶつかった課題を解決したいときに、読み返すのも良いと思います。
上記の対象者以外でも、Chainerを用いる際に、このような設計があるのか、と参考にしていただけたら幸いです。初中級者向けということもあり、記述が冗長ですので、最後のまとめだけでも読んでいただけたら幸いです。
前提知識として、以下のチュートリアルは完了しているものとします。
Chainer v4 ビギナー向けチュートリアル - Qiita
実行環境
- chainer==5.0.0
- chainercv==0.12.0
プロジェクトのディレクトリ構成
この記事では、データセットまわりのみを紹介しますが、Chainer を用いたプロジェクトのディレクトリ構成は以下のようなものを想定しています。
.
├── configs
│ └── {model}.yml # 学習条件の yml ファイル
├── notebooks # デバッグやモデル評価用の ipynb ファイル
└── scripts
├── **datasets # 自作 Dataset 置き場**
├── functions # 重みを持たない関数の置き場
├── links # モデル、重みを持つ関数の置き場
│ └── {model}.py
├── extensions # 自作 Extension 置き場
├── utils
│ ├── read_data.py # 画像などの読み込み
│ ├── transforms.py # 前処理
│ └── visalization.py # 可視化
├── train_{model}.py # 学習スクリプト
└── eval_{model}.py # 評価スクリプト
今回は特に scripts/datasets
に焦点を当てます。
データセットリポジトリの別途作成
この章では、機械学習プロジェクトにおけるデータセット管理の Tips を説明します。
プロジェクトのリポジトリについては前章で説明しましたが、それとは別にデータセット用のリポジトリを作成します。プロジェクトリポジトリの目的は「ある1つのプロジェクト(研究など)に必要なコードの管理」であるのに対し、データセットリポジトリの目的は「ある1つのデータセットのセットアップや加工をするコードの管理」です。
プロジェクトとデータセットでリポジトリを分ける理由は、あるデータセットを他のプロジェクトでも再利用しやすくするためです。プロジェクトに依存しないセットアップや加工のコードはデータセットリポジトリで管理し、プロジェクトに依存するコードはプロジェクトリポジトリで管理します。
また、プロジェクトリポジトリ内のコードからデータセットリポジトリ内のファイルにアクセスする方法として、そのパスを環境変数に設定する方法があります。Pipenv の .env
ファイルで設定すると楽です。具体的な実装は、 Chainer
の Dataset に関する Tips の章で説明します。
データセットリポジトリのディレクトリ構成は以下です。
.
├── README.md # データセットのスキーマやセットアップ手順
├── raw # 第三者による配布を、そのままの形式で置く場所
├── main # raw を使いやすいように加工した本番データ
│ ├── images # 画像
│ └── labels # json, csv, txt などのテキスト
├── tiny # main からサンプルしたデバッグ用の小さいデータ
│ ├── images
│ └── labels
└── make_*.py # 前処理
Polyvore データセットを使って、私が実際に作ったデータセットリポジトリはこちらになります。 こちらを見つつ読み進めていただけると、イメージしやすいと思います。
データセットのダウンロード
今回、用いるデータセットはPolyvoreというファッションコーディネートのデータセットです。 公式リポジトリから polyvore.tar.gz と polyvore-images.tar.gz を raw
にダウンロードします。raw
には基本、第三者が配布したままの形式で置き、read only にします。
今回のデータセットの場合、画像も配布されていますが、json ファイルなどに記載されているURLから画像をダウンロードしなければならない場合もあります。このような場合に使える、非同期処理で高速に大量のファイルをダウンロードする aiodl というCLIツールを作成しましたので、よろしければ使ってください。画像5万枚程度なら30分ほどで終わります。(作った後で気づきましたが、同名で別のPythonライブラリがありますので、ご注意ください。)
また、画像全てをダウンロードする前に、次の節を参考に小さいデータセットを先に用意します。理由は、小さいデータセットを先に用意をしておけば、残りをダウンロードしている間に小さいデータセットで開発を進められるからです。
小さいデータセットの用意
データセットリポジトリに小さいデータセットを用意する理由はデバッグを効率化するためです。
データセットリポジトリにおける具体的な作業は以下です。
-
raw
にダウンロードしたものを、使いやすい形に加工してmain
に置く。 -
main
から、少量のデータをサンプルしてtiny
に置く。
main
から tiny
を作成する際の注意点は、 main
以下と tiny
以下のディレクトリ構成は全く同じにすることです。理由は、プロジェクトのコード内で参照するパスを main
から tiny
に変えるだけで、データセットの規模を切り替えられるようにするためです。
また、json ファイルから少量のデータをサンプルする場合に使える ml_json_processor というCLIを作成したので、よろしければ使ってください。 json ファイルを train と test に分割する際にも使えます。
json ファイルに記載されたURLから画像をダウンロードする場合、小規模の json ファイルを作成したタイミングで make_url_file.py
などのスクリプトを書き、tiny
→ main
の順で画像をダウンロードします。
また、 raw
, main
, tiny
は、データの著作権や容量的な問題で、基本的には .gitignore
に記述して git 管理しないようにします。
ところで、「デバッグを効率良く行う」ことが目的であれば、 以下の用にプロジェクトのコード側でデータの規模を小さくする方法も考えられます。
dataset[:100] # 100 サンプルだけ使う
こちらの方が簡単ですが、データセットが大きすぎる場合、データをメモリに読み込む際に長い時間がかかってしまいます。また、プロジェクトごとに、小さくする記述を書く必要があります。データセットリポジトリ側に責任を持たせることで、プロジェクトのコードに余計な記述をせずに済みますし、データセットの規模の切り替えをプロジェクト間で再利用しやすくなります。また、特殊なケースですが、 Amazon の SageMaker を使う場合も、今回紹介した方が適しています。SageMakerでは学習のたびにS3から学習インスタンスにデータセットをアップロードする必要があるため、小さいデータセットを用意した方がデバッグを効率的に行えます。
重い前処理は予め施しておく
軽い処理(画像の正規化、左右反転など)であれば、後に実装する Dataset クラス内に記述しても良いです。しかし、重い処理(画像特徴量の抽出、ピクセルの値の平均値の計算など)の場合、学習や推論のたびに時間を取られてしまいます。そのため、そのような重い処理は予め施しておき、結果を中間ファイルに保存しておきます。
前処理のスクリプトは、プロジェクトに依存する処理であればプロジェクトリポジトリ側で、汎用的なものであればデータセットプロジェクト側で管理します。
Chainer の Dataset に関する Tips
この章では、 Chainer の Dataset クラスの設計の開発フローの Tips について説明します。この章で使うスクリプトはプロジェクトリポジトリ側で管理します。
Dataset 間で共通の処理は別モジュールに切り分ける
画像の読み込みや、前処理など、 Dataset クラスに依存しない共通の処理は Dataset クラスを定義するモジュールとは分けて定義します。そうすることで、それらの処理を Dataset クラス間で共有でき、保守性が上がります。(Dataset クラスAでは前処理の方法を更新したのに、Dataset クラスBでは更新し忘れていて、学習結果がオジャン、、、といったことも回避できます。)
最初は Dataset クラス内に定義しても良いですが、 Dataset を複数作成する際に、共通部分を別モジュールに切り分けます。このとき、バグが混入しやすいので、学習結果がオジャンにならないよう、しっかりテストをしておきます(後述の可視化モジュールの節の jupyter notebook による視覚的なテストを参照)。
今回の例では、画像の読み込みと前処理のモジュールを定義しておきます。
一応、コードの解説も後述しますが、記事のメインテーマから外れるので、読み流して構いません。
from PIL import Image
from chainercv.transforms import resize
from chainercv.transforms import resize_contain
from chainercv.transforms import scale
from chainercv.utils import read_image
def read_square_img(img_file, keep_aspect=True):
"""
Args:
img_file (str): An image file path.
keep_aspect (bool, optional): Defaults to True.
Returns:
img (np.array: (C=3, H=299, W=299)):
A Chainer format square image.
"""
img = read_image(img_file)
img = resize_img(img, keep_aspect=keep_aspect)
return img
def resize_img(img, size=(299, 299), keep_aspect=False):
"""
Args:
img (np.array: (C=3, H, W)): A Chainer format image.
size (tuple<int>, optional): Defaults to (299, 299).
keep_aspect (bool, optional): Defaults to False.
Returns:
img (np.array: (C=3, H=299, W=299)):
A Chainer format square image.
"""
if keep_aspect:
img = scale(img, size[0], fit_short=False)
img = resize_contain(img, size, fill=255)
else:
img = resize(img, size, Image.BILINEAR)
return img
read_square_img
は、画像ファイルのパスを受け取り、Chainer形式で画像を読み込み、(3, 299, 299)
の正方形にリサイズして返します。
read_img
内の .convert('RGB')
は、白黒画像を読み込んだ場合にも (3, H, W)
の形の配列として読み込むための記述です。
resize_img
は読み込んだ画像を (3, 299, 299)
にリサイズします。 keep_aspect=True
であれば、アスペクト比を保ったままリサイズし、できた余白の部分は白(255)でパディングします。keep_aspect=False
であれば、アスペクト比を無視してリサイズします。
本来、 CNN はアスペクト比の変更にロバストではないため、 keep_aspect=True
とするのが妥当ですが、先行研究ではアスペクト比が無視されていたため、追実験用に keep_aspect 引数を設けました。
from chainercv.transforms import random_flip
def transform_img(img, train=False):
"""
Args:
img (np.array: (c=3, h, w)):
Chainer format image.
Return:
img (np.array: (c=3, h, w)):
Transformed Chainer format image.
"""
# rescale pix value from [0, 255] to [-1, 1]
img = 2 * (img / 255 - 0.5)
if train:
img = random_flip(img, x_random=True)
return img
前処理では、ピクセルの値の正規化と、ランダムな左右反転の data augumentation を行います。 train
引数を設けて、 学習時だけ必要な前処理の切り替えを行います。 **train
のデフォルトは False
** にしておきます。理由は、大抵のプロジェクトの場合、学習より推論の記述をする方が多いからです。
Dataset クラスの実装
chainer.dataset.DatasetMixin
を継承して Dataset クラスを定義します。主にオーバーライドするメソッドは以下の3つです。
__init__
-
__len__
:Dataset 内のデータ数を返します。 Iterator が Dataset を回すときに必要になります。 -
get_example
:データのインデックスを受け取り、そのインデックスのデータを返します。
まず、 __init__
メソッドをオーバーライドします。
import os
import pandas as pd
class PolyvoreDataset(DatasetMixin):
def __init__(
self,
max_num=8,
keep_aspect=True,
json_filename='train_no_dup.json',
):
super().__init__()
self.max_num = max_num
self.keep_aspect = keep_aspect
self.img_dir = os.environ['POLYVORE_IMAGE_DIR']
lbl_dir = os.environ['POLYVORE_LABEL_DIR']
json_file = os.path.join(lbl_dir, json_filename)
self.df = pd.read_json(json_file)
__init__
では、主にデータの読み込みや準備を行います。json や csv ファイルは pandas
のDataFrame
に読み込み、 attribute として持たせておくと、後の処理が楽です。DataFrame の1行が、 get_example
で指定するデータの1サンプルに対応するようにしておきます。今回、データの1サンプルは、1つのコーディネートとしたいです。 Polyvore の json ファイルは以下のようになっているので、 DataFrame に読み込むと、すでに1行が1つのコーディネートを表しているので、今回は特に加工の必要はありません。
[
{
"name": "コーデ名",
"views": 8743,
"items": [
{
"index": 1,
"name": "アイテム名 A",
"price": 24.0,
"likes": 10,
"image": "http://example/image01.jpg",
"categoryid": 4495
},
{
"index": 2,
"name": "アイテム名 B",
"price": 150.0,
"likes": 2250,
"image": "http://example/image02.jpg",
"categoryid": 25
},
...
],
"image": "http://example/tiled/image.jpg",
"likes": 394,
"date": "One month",
"set_url": "http://example/set/",
"set_id": "01234567",
"desc": "コーデの説明"
},
...
]
画像データはメモリにすべて乗らないことがほとんどなので、 get_example
内で逐次、読み込みます。ただ、画像が置いてあるディレクトリのパスは、__init__
内で attribute (self.img_dir
) として持たせておくと楽です。
また、参照するデータがあるディレクトリのパスを環境変数で指定することで、コードのポータビリティが上がります。コードのポータビリティは実験の再現性には欠かせません。 Pipenv を用いている場合、これらの環境変数を、以下のように .env
ファイルに設定しておくのが楽です。
export SIZE="tiny"
export POLYVORE="${HOME}/datasets/Polyvore/${SIZE}"
export POLYVORE_IMAGE_DIR="${POLYVORE}/images"
export POLYVORE_LABEL_DIR="${POLYVORE}/labels"
次に、 __len__
を定義します。DataFrame の長さをとるだけです。
...
class PolyvoreDataset(DatasetMixin):
...
def __len__(self):
return len(self.df)
次に、 get_example
を定義します。データのインデックスを受け取り、そのインデックスのデータを返すメソッドです。 get_example
の返り値であるデータの1サンプルの型は、モデル(の __call__
メソッド)の入力に対応するように設計します。(この場合、モデルの __call__
内で損失関数を呼び出すことを前提としていますが、損失関数をモデルとは別で定義する場合は、損失関数の入力の型に合わせます。)
今回の例では、モデルの学習時の入力は、あるコーディネートを成すアイテム画像の系列ですので、 get_example
の返り値の型も画像の系列とします。具体的に、以下のように実装します。
...
import numpy as np
class PolyvoreDataset(DatasetMixin):
...
def get_example(self, i):
"""
Args:
i (int): An index of a data sample.
Returns:
imgs (np.array: (N, C=3, H, W)):
A set of item images.
N: the number of items in the outfit
"""
outfit = self.df.iloc[i]
items = outfit['items'][:self.max_num]
imgs = np.array([
self.read_img(outfit['set_id'], item['index'])
for item in items
])
# imgs: (N, C=3, H, W)
return imgs
def read_img(self, set_id, index):
img_file = os.path.join(
self.img_dir,
str(set_id),
'{}.jpg'.format(index)
)
return read_square_img(img_file, keep_aspect=self.keep_aspect)
self.df.iloc[i]
で DataFrame の1行を outfit
(コーディネート)として取り出します。items
はリストで、要素は、そのコーデを成すアイテムの情報を持つ辞書です。この items を内包表記で画像の系列に変換しています。その内包表記内で、画像を読み込むときに、 read_img
という read_square_img
をラップしたメソッドを呼んでいます。ラッパーを用意した理由は get_example
内の可読性を良くするためと、モデルの定性評価時などにPolyvoreの画像の読み込みを簡単にするためです。
また、 Dataset クラスに限った話ではありませんが、 テンソル(ndarray)の形をコメントとして明記しておくと可読性があがり、開発しやすいです。
Dataset クラスの実装をまとめると、以下のようになります。
import os
import pandas as pd
import numpy as np
class PolyvoreDataset(DatasetMixin):
def __init__(
self,
max_num=8,
keep_aspect=True,
json_filename='train_no_dup.json',
):
super().__init__()
self.max_num = max_num
self.keep_aspect = keep_aspect
self.img_dir = os.environ['POLYVORE_IMAGE_DIR']
lbl_dir = os.environ['POLYVORE_LABEL_DIR']
json_file = os.path.join(lbl_dir, json_filename)
self.df = pd.read_json(json_file)
def __len__(self):
return len(self.df)
def get_example(self, i):
"""
Args:
i (int): An index of a data sample.
Returns:
imgs (np.array: (N, C=3, H, W)):
A set of item images.
N: the number of items in the outfit
"""
outfit = self.df.iloc[i]
items = outfit['items'][:self.max_num]
imgs = np.array([
self.read_img(outfit['set_id'], item['index'])
for item in items
])
# imgs: (N, C=3, H, W)
return imgs
def read_img(self, set_id, index):
img_file = os.path.join(
self.img_dir,
str(set_id),
'{}.jpg'.format(index)
)
return read_square_img(img_file, keep_aspect=self.keep_aspect)
可視化モジュールの実装
Dataset クラスの実装が一通り終わったら、データのサンプルを可視化するためのモジュールを実装します。可視化モジュールを作る目的は以下の2つです。
- Dataset クラスの視覚的なテスト
- モデルの定性的評価
Dataset クラスにバグがあった場合、学習する際にエラーが出れば、それに気づけます。しかし、 Dataset クラスにいくつもバグがあった場合、学習スクリプトを通してのデバッグだと、原因の切り分けが難しかったり、起動のオーバーヘッドに時間を取られ、非効率です。そのため、Dataset クラス単体でテストをしておきます。特に画像系のデータを扱う場合、可視化しないと気づかないバグ(バウンディングボックスのアノテーションがズレていた、など)もあります。視覚的なテストは jupyter notebook 上で行います。
また、モデルの定性的評価にデータや予測結果の可視化は欠かせません。その際にも、可視化モジュールを作っておくと、効率的な定性的評価が行え、実験サイクルを素早く回せます。
今回、この段階では、アイテム画像1枚、もしくは、画像の系列、を可視化できれば良いので、特に自作はせず、chainercv.visualizations.vis_image と chainercv.utils.tile_images を使います。ただ、今回のプロジェクトでは系列の可視化を良くするので vis_image と tile_images のラッパーを書いておいてもいいかもしれません。
jupyter notebook 上で実行した様子は、以下の図のようになります。

transform の実装
データの1サンプル( Dataset.get_example
の返り値)に対し、前処理を施す transform 関数を実装します。transform は基本、 get_example
が呼ばれる度に実行するので、軽い前処理(画像の正規化、左右反転など)を記述します。重い処理(画像特徴量の抽出、ピクセルの値の平均値の計算など)の場合は別スクリプトにして、学習・推論前に施しておき、結果を中間ファイルに保存しておきます。
transform を関数で定義し、 chainer.datasets.TransformDataset
を用いる方法がありますが、そうではなく、 transform を Dataset クラスのメソッドとして定義し、 get_example
内で呼び出します。get_example
内で trasnform を施す理由は以下のメリットがあるためです。
- Dataset クラスを呼び出す度に transform を施す処理を書かなくて良い。
- ゆえに、transform を施し忘れない。
- 学習・推論スクリプトの記述量が減り、可読性が上がる。
特に、「trasnformを施し忘れる」は、(少なからず私は)初心者のときにしがちなミスです。モデルの性能に致命的なダメージを与えるにも関わらず、エラーを吐かないことも多い悪質なバグです。 get_example
内で施すことにより、忘れる可能性を低くできます。
関数ではなく、 Dataset クラスのメソッドとして持たせる理由は、 transform の命名の手間が省け、名前空間も節約できる点です。また、 Dataset クラスの attribute に依存した処理の記述もしやすいです。 大抵のプロジェクトでは1つの Dataset クラスに対する前処理は同じであり、transform の引数は get_example
の返り値であるため、 1つの Dataset クラスに対し、 1つの transform を定義することになります。ですので、 Dataset クラスのメソッドとして持たせてしまいます。ただ、異なる Dataset クラス間で、画像に対しては同じ前処理を施したい、など、一部の処理を共有させたい場合は、その処理を [transforms.py](http://transforms.py)
などに切り出して、各 Dataset クラスの transform メソッド内で、その処理を呼びます。
今回の例では以下のように実装します。
#v---------------変更点-----------------v
import sys
from os.path import dirname
#^---------------変更点-----------------^
...
#v---------------変更点-----------------v
scripts_dir = dirname(dirname(__file__))
sys.path.append(scritps_dir)
from utils.transforms import transform_img
#^---------------変更点-----------------^
class PolyvoreDataset(DatasetMixin):
def __init__(
self,
max_num=8,
keep_aspect=True,
json_filename='train_no_dup.json',
#v---------------変更点-----------------v
transformed=True,
train=False,
#^---------------変更点-----------------^
):
super().__init__()
self.max_num = max_num
self.keep_aspect = keep_aspect
#v---------------変更点-----------------v
self.train = train
self.transformed = transformed
#^---------------変更点-----------------^
self.img_dir = os.environ['POLYVORE_IMAGE_DIR']
lbl_dir = os.environ['POLYVORE_LABEL_DIR']
json_file = os.path.join(lbl_dir, json_filename)
self.df = pd.read_json(json_file)
...
def get_example(self, i):
...
#v---------------変更点-----------------v
if self.transformed:
imgs = self.transform(imgs)
#^---------------変更点-----------------^
return imgs
...
#v---------------変更点-----------------v
def transform(self, example):
imgs = example
imgs = np.array([
transform_img(img, train=self.train)
for img in imgs
])
return imgs
#^---------------変更点-----------------^
transform を施すか施さないかを決める transfomed
引数のデフォルトは True
にしておきます。 transform の施し忘れを防ぐためです。また、前処理を学習時と推論時で切り替える train
引数も追加しました。
また、画像に対する処理は、他の Dataset クラスでも共有させたいため、transforms.py
に切り出しておき、 transform メソッド内で呼んでいます。
正直、 Dataset クラス内に定義するのでれば、trasnform を定義せず、すべて get_example
内に記述すれば良いとも思います。一応、個人的には「その処理を施したらデータを可視化しづらいかどうか」で、transform か get_example
、どちらに記述するかを分けています。しかし、そうなると、 左右反転やクロッピングなどの augumentation は可視化したいですし、正直、私も良い分け方を見つけられていません。また、応用時を考えて、モデル側に持たせるというのも1つの方法です。
converter の実装
converter の実装はモデルによって必要な場合と、そうでない場合があります。大抵の場合は、 chainer.dataset.concat_examples
を使えば良いので、 自作する必要はありません。 どのような場合に converter を自作するかの説明の前に、 converter の役割について、おさらいしておきます。converter の役割を明確に理解しておくことで、 converter を自作する際の指針になると思います。converter の役割は主に以下の2点です。
- タプルのバッチをバッチのタプルに変換する。
- GPU学習時に ndarray をGPUに転送する。
converter の1つ目の役割の例として、 concat_example では以下の図のような処理をします。
converter の入力は、 Iterator
から渡される、 Dataset.get_exmaple
の返り値(タプル)の list
(バッチ)です。それをバッチのタプルに変換し、それがモデルの入力となります。
1つ目の役割が、 concat_example
では対応できない場合に converter を自作する必要があります。特に、今回の例のようにバッチ内の1サンプルが可変長の場合、バッチ方向に concat して、バッチを1つの ndarray にしてしまう concat_example
では対応できません。ndarray でなく list
のままのバッチである必要があります。
今回は以下のように実装しました。また、 converter の入力は Dataset.get_example
の出力に依存するため、 transform 同様、 Dataset 1つに対し1つの converter が必要になります。ですので、Dataset クラスのメソッドとして持たせます。
import os
import sys
from os.path import dirname
import numpy as np
import pandas as pd
from chainer.dataset import DatasetMixin
#v---------------変更点-----------------v
from chainer.dataset import to_device
#^---------------変更点-----------------^
scripts_dir = dirname(dirname(__file__))
sys.path.append(scripts_dir)
from utils.transforms import transform_img
from utils.read_data import read_square_img
class PolyvoreDataset(DatasetMixin):
...
#v---------------変更点-----------------v
def converter(self, batch, device=-1):
"""
Args:
batch
imgs_b (list<np.array>: B * (N, C, H, W))
A batch of image sequences.
device (int, optional):
>= 0 : GPU
-1 : CPU
"""
imgs_b = batch
imgs_b = [
to_device(device, imgs)
for imgs in imgs_b
]
return imgs_b
#^---------------変更点-----------------^
今回は入力が画像の系列のみなので、 imgs_b = batch
としましたが、データが複数ある場合は、 data1_b, data2_b, ... = list(zip(*batch))
とすることで、タプルのバッチをバッチのタプルに変換できます。
ところで、可変長のサンプルへの対応の処理などを、Iterator や Updater を自作することで対応する方法もあります。しかし、 Iterator の主な役割は「Dataset のサンプルを巡回すること」、 Updater はの主な役割「毎 iteration ごとに、 あるバッチを使ってモデルの重みを更新すること」です。ですので、可変長サンプルへの対応は、「タプルのバッチをバッチのタプルにする」役割を持つ converter に責任を持たせた方が適切だと考えられます。また、 Iterator や Updator より converter の方が処理や構造がシンプルですので、 converter を書き換えた方が簡単です。ですので、 Iterator や Updater をオーバーライドする前に、まず converter のオーバーライドで対応できないかを考えます。
converter も、以下の図の jupyter 上でテストします。

デバッグ時のバッチサイズは基本的に2が良いと考えています。理由は、大きいほど計算に時間がかかりますし、1だと複数のときにしか発生しないバグを見つけられないことがあるからです。
DatasetBase クラスの実装
以上で、1つの Dataset クラスを実装する開発フローは終了です。この節では、複数の Dataset クラスを実装するときの Tips について説明します。 共通部分を多く含んだ、複数の Dataset クラスを作る場合、DatasetBase という親クラスを作ります。例えば、同じデータセット(Polyvore など)でも、学習・評価、評価タスク、モデルによって get_example
の返り値の型を変えなければいけないことがあります。複数の Dataset クラスを作るとなると、jsonファイルの読み込みや、画像ディレクトリのパスの設定など、共通部分が多く保守性が落ちます。(Dataset クラスAの読み込み部分は変更したのに、 Dataset クラスBは変更し忘れていた、などのミスが起きやすくなります。)そこで、親クラスとなる DatasetBase クラスを1枚噛ませ、それに共通部分を持たせます。
今回の例では、FITB(Fill in the Blank)という評価タスクの Dataset クラスを追加してみます。FITBとは以下の図のように、いくつかのアイテム(未完成のコーデ)に合うアイテムを、4つの選択肢から推薦するタスクです。

まず、先ほど定義した PolyvoreDataset
から汎用的な処理を切り出して PolyvoreDatasetBase
クラスを実装します。そして、その DatasetBase クラスを継承して PolyvoreFitbDataset
を実装します。具体的な実装は以下になります。
import os
import sys
from abc import ABCMeta, abstractmethod
from os.path import dirname
import pandas as pd
from chainer.dataset import DatasetMixin
scripts_dir = dirname(dirname(__file__))
sys.path.append(scripts_dir)
from utils.read_data import read_square_img
from utils.transforms import transform_img
class PolyvoreDatasetBase(DatasetMixin):
__metaclass__ = ABCMeta
def __init__(
self,
keep_aspect=True,
json_filename='train_no_dup.json',
):
super().__init__()
self.keep_aspect = keep_aspect
self.img_dir = os.environ['POLYVORE_IMAGE_DIR']
lbl_dir = os.environ['POLYVORE_LABEL_DIR']
json_file = os.path.join(lbl_dir, json_filename)
self.df = pd.read_json(json_file)
def __len__(self):
return len(self.df)
@abstractmethod
def get_example(self, i):
raise NotImplementedError()
def read_img(self, set_id, index):
img_file = os.path.join(
self.img_dir,
str(set_id),
'{}.jpg'.format(index)
)
return read_square_img(img_file, keep_aspect=self.keep_aspect)
def read_img_from_item_id(self, item_id):
set_id, index = item_id.split('_')
return self.read_img(set_id, index)
PolyvoreDataset
から json ファイルの読み込み、画像ディレクトリのパスの設定など、学習・評価、評価タスク、モデルに依存しない部分を PolyvoreDatasetBase
に切り出しました。 get_example
は各 Dataset に依存しますが、必ず必要なので、抽象メソッドにします。 画像の読み込みは学習・評価、評価タスク、モデルに依存せず、1つのデータセットでだいたい同じですので、 read_img
メソッドを DatasetBase クラスに持たせておきます。
続いて、上記の PolyvoreDatasetBase
クラスを継承して作り直した PolyvoreDataset
が以下になります。
import sys
from os.path import dirname
import numpy as np
from chainer.dataset import to_device
scripts_dir = dirname(dirname(__file__))
sys.path.append(scripts_dir)
from utils.transforms import transform_img
from datasets.polyvore_base import PolyvoreDatasetBase
class PolyvoreDataset(PolyvoreDatasetBase):
def __init__(
self,
max_num=8,
transformed=True,
train=False,
*args, **kwargs,
):
super().__init__(*args, **kwargs)
self.max_num = max_num
self.transformed = transformed
self.train = train
def get_example(self, i):
"""
Args:
i (int): An index of a data sample.
Returns:
imgs (np.array: (N, C=3, H, W)):
A set of item images.
N: the number of items in the outfit
"""
outfit = self.df.iloc[i]
items = outfit['items'][:self.max_num]
imgs = np.array([
self.read_img(outfit['set_id'], item['index'])
for item in items
])
# imgs: (N, C=3, H, W)
example = imgs
if self.transformed:
example = self.transform(example)
return example
def transform(self, example):
imgs = example
imgs = np.array([
transform_img(img, train=self.train)
for img in imgs
])
return imgs
def converter(self, batch, device=-1):
"""
Args:
batch
imgs_b (list<np.array>: B * (N, C, H, W))
A batch of image sequences.
device (int, optional):
>= 0 : GPU
-1 : CPU
"""
imgs_b = batch
imgs_b = [to_device(device, imgs) for imgs in imgs_b]
return imgs_b
get_example
は学習時のモデルの __call__
の型に依存するため、子クラスの PolyvoreDataset
に定義します。 また、 transform
も converter
もget_example
の返り値の型に依存するため子クラスに定義します。
最後に、 PolyvoreDatasetBase
を継承して新たに作った評価用データセットである PolyvoreFitbDataset
が以下になります。
import sys
from os.path import dirname
import numpy as np
from chainer.dataset import to_device
scripts_dir = dirname(dirname(__file__))
sys.path.append(scripts_dir)
from utils.transform import transform_img
from datasets.polyvore_base import PolyvoreDatasetBase
class PolyvoreFitbDataset(PolyvoreDatasetBase):
def __init__(
self,
transformed=True,
json_filename='fill_in_blank_test.json',
*args, **kwargs,
):
self.transformed = transformed
kwargs['json_filename'] = json_filename
super().__init__(*args, **kwargs)
self.df['set_id'] = self.df['question']\
.apply(lambda item_ids: item_ids[0].split('_')[0])
self.outfit_ids = self.df['set_id'].tolist()
self.n_choices = len(self.df.iloc[0]['answers'])
def get_example(self, i):
"""
Args:
i (int): An index of a data sample.
Returns
imgs_q (np.array: (n-1, c, h, w))
question image sequence
n: the original number of items in an outfit
imgs_c (np.array: (k, c, h, w))
choice images
k: the number of choices
blank_pos (int)
blank position starting from **1**
"""
row = self.df.iloc[i]
question_ids = row['question']
choice_ids = row['answers']
blank_pos = row['blank_position']
imgs_q = np.array([
self.read_img_from_item_id(item_id)
for item_id in question_ids
])
imgs_c = np.array([
self.read_img_from_item_id(item_id)
for item_id in choice_ids
])
return imgs_q, imgs_c, blank_pos
def transform(self, example):
"""
Args:
example (tuple<imgs_q, imgs_c, blank_pos>)
imgs_q (np.array: (N-1, C, H, W))
A question image sequence.
N: the original number of items in an outfit.
imgs_c (np.array: (K, C, H, W))
A choice images.
K: the number of choices.
blank_pos (int)
The blank position starting from **1**.
Returns:
example (tuple<imgs_q, imgs_c, blank_pos>)
imgs_q (np.array: (N-1, C, H, W))
The transformed question image sequence.
N: the original number of items in an outfit.
imgs_c (np.array: (K, C, H, W))
The transformed choice images.
K: the number of choices.
blank_pos (int)
The blank position starting from **1**.
"""
imgs_q, imgs_c, blank_pos = example
imgs_q = np.array([transform_img(img) for img in imgs_q])
imgs_c = np.array([transform_img(img) for img in imgs_c])
example = imgs_q, imgs_c, blank_pos
if self.transformed:
example = self.transform(example)
return example
def converter(self, batch, device=-1):
""" Convert batch of tuples into tuple of batches
and send them to GPU
Args:
batch (list<tuple<imgs_q, imgs_c, blank_pos>>)
imgs_q (np.array: (N-1, C, H, W))
A question image sequence.
N: the original number of items in an outfit.
imgs_c (np.array: (K, C, H, W))
A choice images.
K: the number of choices.
blank_pos (int)
The blank position starting from **1**.
device (int, optional):
>= 0 : GPU
-1 : CPU
Returns:
imgs_q_b (list<np.array>: B * (N-1, C, H, W))
A batch of question image sequences.
N: the original number of items in an outfit.
imgs_c_b (list<np.array>: B * (K, C, H, W))
A batch of choice image sets.
K: the number of choices.
blank_pos_b (list<int>)
A batch of blank positions starting from **1**.
"""
imgs_q_b, imgs_c_b, blank_pos_b = list(zip(*batch))
imgs_q_b = [to_device(device, imgs) for imgs in imgs_q_b]
imgs_c_b = [to_device(device, imgs) for imgs in imgs_c_b]
return imgs_q_b, imgs_c_b, blank_pos_b
PolyvoreDataset
同様に、 get_example
, transform
, converter
を定義します。 json_filename
を変えているので、ご注意ください。また、こちらも、jupyter notebook でテストします。

上段が質問となるコーデで、下段が選択肢です。選択肢の一番左が正解のアイテムです。
ところで、1つのデータセットに対し、学習・評価、評価タスク、モデルによって get_example の返り値を変えたければ、 Dataset を1つだけ定義し、 __init__
の引数で get_example
の出力を切り替える方法も考えられます。しかし、この方法だと、 get_exmaple
の可読性が落ちますし、モデルを追加する度に Dataset を編集しなければならず、思わぬバグを引き起こす可能性があります。
まとめ
この記事では、ファッションコーディネートモデルのプロジェクトを題材に、データセットの管理と Chainer の Datasetクラスを自作する際のTipsを紹介しました。以下に大事な点をまとめておきます。
- データセットのセットアップや加工にまつわるコードはデータセットリポジトリに切り分ける。
- 小さいデータセットを用意してデバッグを効率化する。
- 重い前処理はあらかじめ施しておく。
- Dataset クラスを自作するときは、
chainer.dataset.DatasetMixin
を継承して、__init__
,__len__
,get_example
の3つオーバーライドする。 - データのパスは環境変数で指定することで、コードのポータビリティを上げる。
-
get_example
の返り値の型は、モデルの入力に合わせて決める。 - テンソルの形はコメントとして明記していおく。
- Dataset クラス、 converter などの単体テストを行う。画像系は特に jupyter notebook を用いて視覚的なテストを行う。
- transform と converter は Dataset のメソッドとして持たせ、transform は
get_example
内で施す。 - converter、 Iterator 、 Updater などの Chainer のオブジェクトの役割を明確に理解し、役割に適したオーバーライドを行う。
- converter の役割は、タプルのバッチをバッチのタプルに変換することと、テンソルをGPUに転送すること。
- DatasetBase クラスに汎用的な処理を切り分けることで、保守性を上げる。
今回は Dataset まわりについての内容でしたが、今後、モデルや学習・評価スクリプトまわりの Tips の記事も書く予定です。
参考URL
chainer: 独自datasetを定義する方法 - 午睡二時四十分