Chainerとチャンピオンモデルでファッションアイテム判別器を作る

f:id:vasilyjp:20180927090637j:plain

こんにちは、データチームの後藤です。この記事では、一般物体認識で優秀な成績を収めた代表的なニューラルネットワークモデルを、ファッションアイテムの画像データに対して適用し、どのアーキテクチャが有用か、どれだけの精度を出せるのかを調べる実験を行います。

今回は、

  • AlexNet
  • Network In Network
  • GoogLeNet
  • DenseNet

の4つのアーキテクチャを試しました。

背景

iQONでは毎日500以上のECサイトをクロールし、一日平均1万点もの新着アイテムを追加しています。この過程で、新着アイテムがiQONのどのカテゴリに属するのかを決める必要がありますが、この作業を人手で行うと膨大なコストになってしまいます。この問題に対して我々は、アイテムの名前や説明文、画像データを活用してカテゴリを判別する仕組みを作りました。とくに画像データによる判別には、畳み込みニューラルネットワーク(CNN)を活用しています。その一例は過去のブログに書いていますので興味のある方は是非ご覧ください。

tech.vasily.jp

過去の例では、5層の比較的浅いCNNを利用しましたが、ニューラルネットワークの研究は日々進歩しており、カテゴリ判別問題に対して有効なアーキテクチャが次々と提案されています。そこで今回は、新しいアーキテクチャの能力をファッションアイテムのデータで試し、その精度や有用性を検討してみたいと思います。いずれのネットワークにも、ファッションアイテムの画像の入力に対して、カテゴリを予測する判別器を学習させます。

データ

f:id:vasilyjp:20161013155551p:plain

今回はファッションアイテムの画像を34カテゴリに分け、各カテゴリ1000枚から5000枚程度の画像を用意しました。画像の数は合計で63348枚になりました。目視で正しいカテゴリであることを確認しています。全体のうち、9割を学習用に、1割をテストに充てます。

学習環境

ニューラルネットワークの学習には、以下の環境を用います。ディープラーニングのフレームワークとしてはPFNのChainerを用います。

開発機
  • OS: Ubuntu 14.04
  • GPU: NVIDIA GTX 1080 (初任給)
ソフト
  • cuda: 8.0
  • cuDNN: 5.0RC
  • Python: 3.5.1
  • chainer: 1.16.0

Network

一般物体認識において優秀な成績を収めているチャンピオンモデルを試します。AlexNet、GoogLeNetなどはすでにChainerのexamplesに用意されていますが、 データだけ差し替えても動かなかったため、必要に応じて書き換えています。動かない原因はこのバグによるものだと考えています。Imagenet example failed to evaluate the model #1691

AlexNet

f:id:vasilyjp:20161013155649p:plain

[pdf] ImageNet Classification with Deep Convolutional Neural Networks

2012年のILSVRCで一世を風靡したチャンピオンモデルです。以下のネットワークは、examplesのコードで省かれていた入力次元を記述したものです。入力次元をNoneではなく、数値で記述すると動きました。(2016年10月11日時点)

import numpy as np

import chainer
import chainer.functions as F
from chainer import initializers
import chainer.links as L


class Alex(chainer.Chain):

    """Single-GPU AlexNet without partition toward the channel axis."""

    insize = 227

    def __init__(self):
        super(Alex, self).__init__(
            conv1=L.Convolution2D(3,  96, 11, stride=4),
            conv2=L.Convolution2D(96, 256,  5, pad=2),
            conv3=L.Convolution2D(256, 384,  3, pad=1),
            conv4=L.Convolution2D(384, 384,  3, pad=1),
            conv5=L.Convolution2D(384, 256,  3, pad=1),
            fc6=L.Linear(9216, 4096),
            fc7=L.Linear(4096, 4096),
            fc8=L.Linear(4096, 34),
        )
        self.train = True

    def __call__(self, x, t):
        h = F.max_pooling_2d(F.local_response_normalization(
            F.relu(self.conv1(x))), 3, stride=2)
        h = F.max_pooling_2d(F.local_response_normalization(
            F.relu(self.conv2(h))), 3, stride=2)
        h = F.relu(self.conv3(h))
        h = F.relu(self.conv4(h))
        h = F.max_pooling_2d(F.relu(self.conv5(h)), 3, stride=2)
        h = F.dropout(F.relu(self.fc6(h)), train=self.train)
        h = F.dropout(F.relu(self.fc7(h)), train=self.train)
        h = self.fc8(h)

        loss = F.softmax_cross_entropy(h, t)
        chainer.report({'loss': loss, 'accuracy': F.accuracy(h, t)}, self)
        return loss

NIN

f:id:vasilyjp:20161013155723p:plain

[arXiv]Network In Network

Network in Networkは疎性結合による特徴量抽出部分に畳み込みではなく多層パーセプトロンを使っているのが特徴です。

import math

import chainer
import chainer.functions as F
import chainer.links as L

class NIN(chainer.Chain):

    """Network-in-Network example model."""

    insize = 227

    def __init__(self):
        w = math.sqrt(2)  # MSRA scaling
        super(NIN, self).__init__(
            mlpconv1=L.MLPConvolution2D(
                3, (96, 96, 96), 11, stride=4, wscale=w),
            mlpconv2=L.MLPConvolution2D(
                96, (256, 256, 256), 5, pad=2, wscale=w),
            mlpconv3=L.MLPConvolution2D(
                256, (384, 384, 384), 3, pad=1, wscale=w),
            mlpconv4=L.MLPConvolution2D(
                384, (1024, 1024, 34), 3, pad=1, wscale=w),
        )
        self.train = True

    def __call__(self, x, t):
        h = F.max_pooling_2d(F.relu(self.mlpconv1(x)), 3, stride=2)
        h = F.max_pooling_2d(F.relu(self.mlpconv2(h)), 3, stride=2)
        h = F.max_pooling_2d(F.relu(self.mlpconv3(h)), 3, stride=2)
        h = self.mlpconv4(F.dropout(h, train=self.train))
        h = F.reshape(F.average_pooling_2d(h, 6), (x.data.shape[0], 34))

        loss = F.softmax_cross_entropy(h, t)
        chainer.report({'loss': loss, 'accuracy': F.accuracy(h, t)}, self)
        return loss
GoogLeNet

f:id:vasilyjp:20161013155747p:plain

[pdf]Going Deeper with Convolution

インセプションと呼ばれる複数の畳み込みフィルタを並列に用いるモジュールを積み上げることで、効率的にパラメータの数を減らすことに成功しています。

import numpy as np

import chainer
import chainer.functions as F
from chainer import initializers
import chainer.links as L


class GoogLeNetBN(chainer.Chain):

    """New GoogLeNet of BatchNormalization version."""

    insize = 224

    def __init__(self):
        super(GoogLeNetBN, self).__init__(
            conv1=L.Convolution2D(3, 64, 7, stride=2, pad=3, nobias=True),
            norm1=L.BatchNormalization(64),
            conv2=L.Convolution2D(64, 192, 3, pad=1, nobias=True),
            norm2=L.BatchNormalization(192),
            inc3a=L.InceptionBN(192, 64, 64, 64, 64, 96, 'avg', 32),
            inc3b=L.InceptionBN(256, 64, 64, 96, 64, 96, 'avg', 64),
            inc3c=L.InceptionBN(320, 0, 128, 160, 64, 96, 'max', stride=2),
            inc4a=L.InceptionBN(576, 224, 64, 96, 96, 128, 'avg', 128),
            inc4b=L.InceptionBN(576, 192, 96, 128, 96, 128, 'avg', 128),
            inc4c=L.InceptionBN(576, 128, 128, 160, 128, 160, 'avg', 128),
            inc4d=L.InceptionBN(576, 64, 128, 192, 160, 192, 'avg', 128),
            inc4e=L.InceptionBN(576, 0, 128, 192, 192, 256, 'max', stride=2),
            inc5a=L.InceptionBN(1024, 352, 192, 320, 160, 224, 'avg', 128),
            inc5b=L.InceptionBN(1024, 352, 192, 320, 192, 224, 'max', 128),
            out=L.Linear(1024, 34),

            conva=L.Convolution2D(576, 128, 1, nobias=True),
            norma=L.BatchNormalization(128),
            lina=L.Linear(2048, 1024, nobias=True),
            norma2=L.BatchNormalization(1024),
            outa=L.Linear(1024, 34),

            convb=L.Convolution2D(576, 128, 1, nobias=True),
            normb=L.BatchNormalization(128),
            linb=L.Linear(2048, 1024, nobias=True),
            normb2=L.BatchNormalization(1024),
            outb=L.Linear(1024, 34),
        )
        self._train = True

    @property
    def train(self):
        return self._train

    @train.setter
    def train(self, value):
        self._train = value
        self.inc3a.train = value
        self.inc3b.train = value
        self.inc3c.train = value
        self.inc4a.train = value
        self.inc4b.train = value
        self.inc4c.train = value
        self.inc4d.train = value
        self.inc4e.train = value
        self.inc5a.train = value
        self.inc5b.train = value

    def __call__(self, x, t):
        test = not self.train

        h = F.max_pooling_2d(
            F.relu(self.norm1(self.conv1(x), test=test)),  3, stride=2, pad=1)
        h = F.max_pooling_2d(
            F.relu(self.norm2(self.conv2(h), test=test)), 3, stride=2, pad=1)

        h = self.inc3a(h)
        h = self.inc3b(h)
        h = self.inc3c(h)
        h = self.inc4a(h)

        a = F.average_pooling_2d(h, 5, stride=3)
        a = F.relu(self.norma(self.conva(a), test=test))
        a = F.relu(self.norma2(self.lina(a), test=test))
        a = self.outa(a)
        loss1 = F.softmax_cross_entropy(a, t)

        h = self.inc4b(h)
        h = self.inc4c(h)
        h = self.inc4d(h)

        b = F.average_pooling_2d(h, 5, stride=3)
        b = F.relu(self.normb(self.convb(b), test=test))
        b = F.relu(self.normb2(self.linb(b), test=test))
        b = self.outb(b)
        loss2 = F.softmax_cross_entropy(b, t)

        h = self.inc4e(h)
        h = self.inc5a(h)
        h = F.average_pooling_2d(self.inc5b(h), 7)
        h = self.out(h)
        loss3 = F.softmax_cross_entropy(h, t)

        loss = 0.3 * (loss1 + loss2) + loss3
        accuracy = F.accuracy(h, t)

        chainer.report({
            'loss': loss,
            'loss1': loss1,
            'loss2': loss2,
            'loss3': loss3,
            'accuracy': accuracy,
        }, self)
        return loss
DenseNet

f:id:vasilyjp:20161013155808p:plain

[arXiv] Densely Connected Convolutional Networks

今年の8月に登場したアーキテクチャです。各層のアウトプットをすべて次の層のインプットにするというシンプルなアーキテクチャですが、精度、パラメータ数削減、汎化性能などの点で優れているようです。前の3つのネットワークで使った画像サイズに合わせたネットワークを作ろうとすると、GPUメモリに収まらなかったため、論文と同様の32×32の画像をインプットとしました。

実装はいくつかありますが、以下のChainerによる実装を参考にしました。 https://github.com/yasunorikudo/chainer-DenseNet

loss関数とinitializerの部分を変更しています。 今回は、growth_rateは12、DenseBlockの数は3、各DenseBlockは40層としています。

import chainer
import chainer.functions as F
import chainer.links as L
from chainer import initializers
from six import moves
import numpy as np

class DenseBlock(chainer.Chain):
    def __init__(self, in_ch, growth_rate, n_layer):
        self.dtype = np.float32
        self.n_layer = n_layer
        super(DenseBlock, self).__init__()
        for i in moves.range(self.n_layer):
            W = initializers.HeNormal(1 / np.sqrt(2), self.dtype)
            self.add_link('bn{}'.format(i + 1),
                          L.BatchNormalization(in_ch + i * growth_rate))
            self.add_link('conv{}'.format(i + 1),
                          L.Convolution2D(in_ch + i * growth_rate,
                                          growth_rate, 3, 1, 1, initialW=W))

    def __call__(self, x, dropout_ratio, train):
        for i in moves.range(1, self.n_layer + 1):
            h = F.relu(self['bn{}'.format(i)](x, test = not train))
            h = F.dropout(self['conv{}'.format(i)](h), dropout_ratio, train)
            x = F.concat((x, h))
        return x

class Transition(chainer.Chain):
    def __init__(self, in_ch):
        self.dtype = np.float32
        W = initializers.HeNormal(1 / np.sqrt(2), self.dtype)
        super(Transition, self).__init__(
            bn=L.BatchNormalization(in_ch),
            conv=L.Convolution2D(in_ch, in_ch, 1, initialW=W))

    def __call__(self, x, dropout_ratio, train):
        h = F.relu(self.bn(x, test=not train))
        h = F.dropout(self.conv(h), dropout_ratio, train)
        h = F.average_pooling_2d(h, 2)
        return h


class DenseNet(chainer.Chain):
    def __init__(self, n_layer=12, growth_rate=12,
                 n_class=34, dropout_ratio=0.2, in_ch=16, block=3):
        
        """DenseNet definition.
        Args:
            n_layer: Number of convolution layers in one dense block.
                If n_layer=12, the network is made out of 40 (12*3+4) layers.
                If n_layer=32, the network is made out of 100 (32*3+4) layers.
            growth_rate: Number of output feature maps of each convolution
                layer in dense blocks, which is difined as k in the paper.
            n_class: Output class.
            dropout_ratio: Dropout ratio.
            in_ch: Number of output feature maps of first convolution layer.
            block: Number of dense block.
        """

        self.dtype = np.float32
        self.insize = 32
        self.dropout_ratio = dropout_ratio
        in_chs = moves.range(
            in_ch, in_ch + (block + 1) * n_layer * growth_rate,
            n_layer * growth_rate)
        W = initializers.HeNormal(1 / np.sqrt(2), self.dtype)

        super(DenseNet, self).__init__()
        self.add_link('conv1', L.Convolution2D(3, in_ch, 3, 1, 1, initialW=W))
        for i in moves.range(block):
            self.add_link('dense{}'.format(i + 2),
                          DenseBlock(in_chs[i], growth_rate, n_layer))
            if not i == block - 1:
                self.add_link('trans{}'.format(i + 2), Transition(in_chs[i + 1]))
        self.add_link(
            'bn{}'.format(block + 1), L.BatchNormalization(in_chs[block]))
        self.add_link('fc{}'.format(block + 2), L.Linear(in_chs[block], n_class))
        self.train = True
        self.dropout_ratio = dropout_ratio
        self.block = block

    def __call__(self, x, t):
        h = self.conv1(x)
        for i in moves.range(2, self.block + 2):
            h = self['dense{}'.format(i)](h , self.dropout_ratio, self.train)
            if not i == self.block + 1:
                h = self['trans{}'.format(i)](h, self.dropout_ratio, self.train)
        h = F.relu(self['bn{}'.format(self.block + 1)](h, test=not self.train))
        h = F.average_pooling_2d(h, h.data.shape[2])
        h = self['fc{}'.format(self.block + 2)](h)

        loss = F.softmax_cross_entropy(h, t)
        chainer.report({'loss': loss, 'accuracy': F.accuracy(h, t)}, self)
        return loss

学習

学習部分のコードは、chainer/examaples/imagenet/train_imagenet.pyが非常に有用です。必要なデータだけをその都度マルチプロセスで読み込み学習することでメモリの消費を抑えることができます。メモリに乗り切らない大規模なデータを学習させる際は必須です。以前のバージョンでは、feeder、logger、train_loopがコード内にきっちり記述されていましたが、最近のバージョンのコードを見直してみると、複雑で弄るのが難しい部分が上手く隠されており、読みやすいコードになっていました。

各ネットワークを公平な評価にするために、条件をできるだけ揃えます。OptimizerにはMomentumSGDを用い、learning rateは0.01から開始し、30epochを回った所で、0.001に下げ、50epochまで学習させます。平均画像を引く、cropするといった前処理は行っていません。

結果

network train acc test acc Layers input
AlexNet 99.68% 72.49% 8 3×227×227
Network In Network 99.09% 75.70% 12 3×227×227
GoogLeNet 99.85% 81.14% 22 3×224×224
DenseNet 84.53% 75.20% 120 3×32×32

DenseNet以外のいずれのモデルも、学習データを入力とした場合の精度は99%を超えました。その中でも、未知のデータに対してもっとも精度が高かったのはGoogLeNetでした。一般に、層の数を増やすと過学習する恐れがありますが、GoogLeNetのアーキテクチャはうまく汎化してくれるようです。一方、DenseNetは小さなインプットデータを用いている分、他のネットワークより不利なはずですが、Accuracy 75.20%と健闘しています。学習データでも99%を出せてないことから、32×32は、227×227に比べて情報が落ちていると考えられますが、それでもAlexNetの成績を超え、NINと同等の成績を出しているという点で性能の高いアーキテクチャと言えるでしょう。GoogLeNetとDenseNetの条件を揃えた上での性能比較は、今回は難しかったので省きます。

議論

定義の曖昧なファションアイテムのカテゴリ分け

もっとも精度の高かったGoogLeNetのモデルにカテゴリ判別をさせ、confusion matrixを図示してみます。

f:id:vasilyjp:20161013155831p:plain

対角成分が赤いカテゴリは判別精度が高く、黄色や緑色の場合は判別精度が悪いことを意味します。対角成分が緑色のカテゴリに注目すると、「シューズその他」、「帽子その他」などの「その他」カテゴリの精度が弱いということがわかります。「その他」カテゴリというのは、例えばシューズ系であれば、「パンプス」、「スニーカー」、「サンダル」、「ブーツ」以外のシューズを含めるためのカテゴリです。代表的なカテゴリ以外のアイテムも何らかのカテゴリを与えるために、このようなカテゴリを設けています。定義の不明瞭なカテゴリであるため、様々な特徴をもつシューズ系のアイテムが集められ、判別が難しいカテゴリとなっていると考えられます。

判別が難しい例は「その他」カテゴリに限らず、例えば「ロングパンツ」の中に、「スカート」と見分けがつかないものがあったり、「トップス」と「ルームウェア」など意味が重複するカテゴリがあったりします。つまり、画像データだけから完璧なカテゴリ判別器をつくるのは難しそうです。

精度80%のモデルをどう使うか

それでもiQONのサービス上、ユーザーはカテゴリ毎にアイテムを閲覧することが多いので、カテゴリをきっちりと判別できている必要があります。ブラウザでは1ページで30件のアイテムが表示されるので、20%も間違いがあるとかなりの違和感があります。よって、精度80%のモデルができたとしても、全てのアイテムの判別をこのモデルに委ねる訳にはいきません。

予測カテゴリをそのまま使うのではなく、一つ前の段階のsoftmax関数のアウトプットを使うという方法が考えられます。softmax関数のアウトプットはカテゴリに属する確率として捉えることができるので、softmaxのアウトプットが閾値を超えたときだけ判別結果を採用するといった方法や、判別を間違えないと分かったカテゴリのみ判別結果を採用するといった工夫をすることで誤判定を減らすことができそうです。

もう一つの使い方として、softmax関数の出力を新たなインプットとして、他の情報と組み合わせて判別につかうという方法も考えられます。画像データで数カテゴリに候補を絞りながら、ブランドやアイテム名などで情報を補えば、より正確にカテゴリの判別が行えます。実際に、iQONのカテゴリ判別器は、AlexNetのアウトプットを新たな特徴量として扱い、アイテムの名前やブランド、ドメインなど他の情報を合わせた判別器を学習させることで判別精度を上げています。

まとめ

ファッションアイテムの画像データに対して、ニューラルネットワークによる一般物体認識モデルを学習させました。今回試した4つのアーキテクチャの中では、GoogLeNetがもっとも精度が高いことがわかりました。不利な条件で高い精度を誇ったDenseNetも条件を揃えることで、どんなパフォーマンスを発揮するか気になるところです。 また、ファッションアイテムのカテゴリの特徴上、画像だけでは判別できないアイテムも存在します。実際には、画像によるカテゴリ判別器単体で使うことは難しく、他の情報も併用しながら、精度を上げていくといった工夫が必要になりそうです。

最後に

VASILYでは、最新の研究にアンテナを張りながら、同時にユーザーの課題解決を積極的に行うメンバーを募集しています。 興味のある方はこちらからご応募ください。

カテゴリー