画像の一部をマスクするオーグメンテーションのまとめ (Random Erasing, Cutout, Hide-and-Seek, GridMask)

画像の一部をマスクすることでオーグメンテーションする手法について代表的なものをまとめました。

そもそも: なぜ画像の一部をマスクするのか?

オーグメンテーション全体に言えることですが、モデルの汎化能力を向上させることが目的です。

他のオーグメンテーション手法と比較して、遮蔽やノイズに対してロバストにする点が特徴的です。

  • ランダムフリップと異なり、情報を損失する
  • ランダムクロップと異なり、対象の全体的な構造をキープ
  • ドロップアウトと異なり、連続した矩形領域の情報を落とす

また、これらオーグメンテーション手法と相補的で、組み合わせて使うこともできます。

Random erasing

大きさがランダムの矩形領域で画像をマスクするのが Random erasing1 です。矩形領域のRGBにはランダムな値 (0〜255となる一様分布で生成) で埋められます。

  • 必要なハイパパラメータは3つ
    • 画像全体に対するマスク領域の面積比の最小・最大
    • マスク領域の縦横比のレンジ
    • Random erasingを適用する確率
  • 固定値ではなくランダムな値で埋めることで、ノイズとして機能している
    • 固定値 (0や255) で埋めるよりも精度が高くなることをCIFAR-10で確認
    • 後述する Hide-and-Seek では学習データの平均値を使うことを推奨してます

[1] Figure 1 抜粋

物体検出タスクでは、画像全体をマスクする、物体ごとにマスクする、両方を組み合わせてマスクする、という3パターンの適用方法をオプションとして提案しています。

  • VOC2007 testでは3番目が一番高いmAPをマークしてます (面積比0.02〜0.2、縦横比0.3、確率0.5を用いた場合)

[1] Figure 2 抜粋

Cutout

Random erasingとほぼ時同じくして提案された Cutout2 はもっとシンプルで、画像中のランダムな位置を中心として正方形領域 (辺の長さは固定) を固定値0でマスクします。3

  • 位置によっては全ての正方形領域を含まない (画像外にはみ出す) ケースがある
    • 常に正方形領域が画像内に収まるように調整するよりも、パフォーマンスは良かった (画像の大部分を残すことが重要)
  • Cutoutの適用確率は50%

[2] Figure 1 抜粋

画像分類タスク (CIFAR-10, CIFAR-100など) において精度改善が見られる。正方形領域の辺の長さ (下図Patch Length) が重要で、同じ画像サイズ (32 x 32) でもより細かな情報が必要となるタスクの場合は短く設定するなどのチューニングが必要です。

[2] Figure 3 抜粋

中間層の活性化度合いの偏りを見たところ、Cutoutを導入することでより広範囲の特徴を利用するように均されていることがわかります。

[2] Figure 4 抜粋

Hide-and-Seek

Random erasingやCutoutでは、重要な情報を落としすぎたり、逆に全く意味のない情報を落としてしまったりといったことが発生してしまいます。

画像を小さな正方形で分割し、このグリッドの各マスを一定確率でマスクするアルゴリズムとして Hide-and-Seek4 が提案されています。グリッド分割することよって、対象物全体を削除したり、あるいは、全く関係のない部分を削除してしまうといった問題を低減しています。

[4] Figure 2 抜粋

GridMask

Hide-and-Seekでも、連続したグリッドがたまたまマスクされることで、重要な情報を落としてしまうといったことが起こりえます。

重要な情報を落としすぎない = 隠されていない部分でなんとか学習できるくらいの"ちょうど良い" 大きさでマスクし、かつ極めて簡単に実装できるアルゴリズムとして GridMask5 が提案されてます。以下のように四角形のマスクを縦横一定の間隔でかけることで、このちょうど良さを実現しようとします。

  • この逆に、正方形領域を残す (グリッド枠の部分をマスクする) などのバリエーションがあります

[5] Figure 1 抜粋

GridMaskのハイパパラメータ4つです。それぞれ指針が示されています。

  • マスクされない領域の辺の長さの割合 (r)
    • rが小さくなるほどマスク領域は大きくなる
    • 一定の割合でマスクされるように固定する
    • 複雑なデータセットほどrを大きくとることで細かな情報まで得られやすいようにする
  • 1グリッドの辺の長さ (d)
    • rが一定ならば、dが大きくなるほど1つのマスク領域が大きくなる
    • 最小値・最大値を定めてランダムにセットするのが効果的 (ただし小さすぎると畳み込み演算でかき消されてしまうため無意味になる)
  • マスクとなる長方形の縦横の長さ ( delta_y, delta_x)
    • dとrを満たすようにランダムにセット

[5] Figure 4 抜粋

まとめ

画像の一部をマスクするオーグメンテーションアルゴリズムを4つ紹介しました。


  1. [1708.04896] Random Erasing Data Augmentation

  2. [1708.04552] Improved Regularization of Convolutional Neural Networks with Cutout

  3. Cutoutの初期構想はもっと複雑で、フォワード後の特徴マップをアップサンプリングして活性化された領域 (= 重要な特徴となっている領域) を特定し、次のエポックでその領域をマスクするというものだったようです。これはこれで上手くいったのですが、固定サイズをランダムに除去した場合と大差はなかったとのこと。

  4. [1811.02545] Hide-and-Seek: A Data Augmentation Technique for Weakly-Supervised Localization and Beyond

  5. [2001.04086] GridMask Data Augmentation

物体検出で重なったバウンディングボックスを除去・集約するアルゴリズムのまとめ (NMS, Soft-NMS, NMW, WBF)

物体検出の分野では、検出した物体をバウンディングボックス (BBox) で囲んで、それぞれに信頼度 (スコア) を算出します。

このとき重複したBBoxを除去あるいは集約するアルゴリズムにはバリエーションがあります。物体検出モデルの後処理やコンペなどでよく使われる4つを紹介します。

  • NMS
  • Soft-NMS
  • NMW
  • WBF

最初におさらい: IoU (Intersection over Union)

2つのBBoxがどれくらい重複しているかを表す指標の1つで、1.0に近づくほど重複しています。

  • 分子が重なっている面積
  • 分母が2つのBBoxの総面積


IoU = \frac{Intersection}{Union}

実装

def iou(a: tuple, b: tuple) -> float:
    a_x1, a_y1, a_x2, a_y2 = a
    b_x1, b_y1, b_x2, b_y2 = b
    
    if a == b:
        return 1.0
    elif (
        (a_x1 <= b_x1 and a_x2 > b_x1) or (a_x1 >= b_x1 and b_x2 > a_x1)
    ) and (
        (a_y1 <= b_y1 and a_y2 > b_y1) or (a_y1 >= b_y1 and b_y2 > a_y1)
    ):
        intersection = (min(a_x2, b_x2) - max(a_x1, b_x1)) * (min(a_y2, b_y2) - max(a_y1, b_y1))
        union = (a_x2 - a_x1) * (a_y2 - a_y1) + (b_x2 - b_x1) * (b_y2 - b_y1) - intersection
        return intersection / union
    else:
        return 0.0
    
assert iou((10, 20, 20, 30), (10, 20, 20, 30)) == 1.0
assert iou((10, 20, 20, 30), (20, 20, 30, 30)) == 0.0
assert 0.142 < iou((10, 20, 20, 30), (15, 25, 25, 35)) < 0.143
assert 0.111 < iou((10, 10, 40, 40), (20, 20, 30, 30)) < 0.112

NMS (Non-Maximum Suppression)

IoUがある閾値を超えて重なっているBBoxの集合から、スコアが最大のBBoxを残して、それ以外を除去するのがNMS1です。

f:id:ohke:20200620094403p:plain

実装

def nms(bboxes: list, scores: list, iou_threshold: float) -> list:
    new_bboxes = []
    
    while len(bboxes) > 0:
        i = scores.index(max(scores))
        bbox = bboxes.pop(i)
        scores.pop(i)
        
        deletes = []
        for j, (bbox_j, score_j) in enumerate(zip(bboxes, scores)):
            if iou(bbox, bbox_j) > iou_threshold:
                deletes.append(j)
                
        for j in deletes[::-1]:
            bboxes.pop(j)
            scores.pop(j)
                
        new_bboxes.append(bbox)
        
    return new_bboxes
    
assert nms([(10, 20, 20, 30), (20, 20, 30, 30), (15, 25, 25, 35)], [0.8, 0.7, 0.9], 0.1) == [(15, 25, 25, 35)]

Soft-NMS

NMSではスコアが最大のBBox以外は除去されていましたが、例えば以下のように、同じラベルの物体が重なりあっている場合に誤って除去してしまう (再現率を落とす) ことが起こりえます。

f:id:ohke:20200620092744p:plain

こうしたケースに着目し、IoU閾値を超えたBBoxを残しつつ、代わりにスコアを割り引くのがSoft-NMS2です。

f:id:ohke:20200620133111p:plain

割引きの計算式は以下2つが提案されていますが、いずれもIoUが大きくなる (= より重複している) とより多く割り引くようになっています。

実装

先程のNMSの実装を少し変えるだけで実現できます。割り引き計算には上の1つ目の式を用いています。

def soft_nms(bboxes: list, scores: list, iou_threshold: float) -> (list, list):
    new_bboxes, new_scores = [], []
    
    while len(bboxes) > 0:
        i = scores.index(max(scores))
        bbox = bboxes.pop(i)
        score = scores.pop(i)
        
        for j, (bbox_j, score_j) in enumerate(zip(bboxes, scores)):
            iou_j = iou(bbox, bbox_j)
            if iou_j > iou_threshold:
                scores[j] = score_j * (1 - iou_j)
                
        new_bboxes.append(bbox)
        new_scores.append(score)
        
    return new_bboxes, new_scores
    
print(soft_nms([(10, 20, 20, 30), (20, 20, 30, 30), (15, 25, 25, 35)], [0.8, 0.7, 0.9], 0.1))
# ([(15, 25, 25, 35), (10, 20, 20, 30), (20, 20, 30, 30)], [0.9, 0.6857142857142858, 0.6])

Non-Maximum Weighted (NMW)

NMSとSoft-NMSは、いずれもBBoxの座標情報を変更していません。以下のように、いずれのBBoxでも正確に捉えきれていない場合、BBoxのどれかを残すのではなく、"良い感じに"ミックスして平均的なBBoxを作るほうが全体を上手に捉えることができそうな気がしてきます。

f:id:ohke:20200620231231p:plain

NMW3は、重なりあったBBoxをスコアとIoUで重み付けして足し合わせることで、1つの新たなBBoxを作り出すアルゴリズムです。

  •  b_{pre} が新たに作るBBoxです
  •  b_{argmax_{i} c_{i}}はスコアが最も高いBBoxです (つまりNMSなら残されるBBoxです)

実装

IoUが低いのでそれほど目立ってないですが、わずかにBBox (15, 25, 25, 35) が他の2つに近づいていることがわかります。

def nmw(bboxes: list, scores: list, iou_threshold: float) -> list:
    new_bboxes = []
    
    while len(bboxes) > 0:
        i = scores.index(max(scores))
        bbox = bboxes.pop(i)
        score = scores.pop(i)
        
        numerator = (bbox[0] * score, bbox[1] * score, bbox[2] * score, bbox[3] * score)
        denominator = score
        deletes = []
        for j, (bbox_j, score_j) in enumerate(zip(bboxes, scores)):
            iou_j = iou(bbox, bbox_j)
            if iou_j > iou_threshold:
                w = scores[j] * iou_j
                numerator = (
                    numerator[0] + bbox_j[0] * w, numerator[1] + bbox_j[1] * w,
                    numerator[2] + bbox_j[2] * w, numerator[3] + bbox_j[3] * w
                )
                denominator += w
                
                deletes.append(j)
                
        for j in deletes[::-1]:
            bboxes.pop(j)
            scores.pop(j)
          
        new_bboxes.append((
            numerator[0] / denominator, numerator[1] / denominator,
            numerator[2] / denominator, numerator[3] / denominator
        ))
        
    return new_bboxes
    
print(nmw([(10, 20, 20, 30), (20, 20, 30, 30), (15, 25, 25, 35)], [0.8, 0.7, 0.9], 0.1))
# [(14.935897435897434, 24.038461538461537, 24.935897435897434, 34.03846153846154)]

Weighted Boxes Fusion (WBF)

これまで触れてきたアルゴリズムは、重なり合わないBBoxについては何もせず残します。同じ画像に対して複数の推論を行う場合 (複数モデルでアンサンブルする、オーグメンテーションをかけるなど) 、これが誤検出につながるケースがあります。

3つのモデルで"dog"を検出する推論をかけ、以下のようにBBoxが出力されたとします。ある1つのモデルだけ左の人を検出してしまっています。NMS・Soft-NMS・NMWでは、この左の誤ったBBoxを除去したりスコアを下げたりできません。

WBF4は、複数モデルの推論結果を集約することを主目的としたアルゴリズムです。

複数モデルで推論させて得られたBBoxから、IoUを閾値として重複するBBoxを見つけ出します。このT個の重複したBBoxについて、スコア (C) と座標 (X1, X2, Y1, Y2) を以下の式で計算します。スコアで重み付けされた平均値となっており、この時点ではIoUを用いていません。

全てのBBoxについてスコアと座標の計算が終わった後、以下のいずれかの式でスコアをモデルの数 (N) で正規化します。これにより、検出されたモデルの数 (つまりT) が少ないBBoxほどスコアが下がるため、上のように1つのモデルだけで検出されたBBoxをスコアで足切りすることができるようになります。

実装

以下の通り、重なり合うBBoxは高いスコア (0.8) をキープしますが、重なっていないBBox (30, 30, 40, 40) は元のスコアの1/3されています。

def wbf(bboxes: list, scores: list, iou_threshold: float, n: int) -> (list, list):
    lists, fusions, confidences = [], [], []
    
    indexes = sorted(range(len(bboxes)), key=scores.__getitem__)[::-1]
    for i in indexes:
        new_fusion = True
        
        for j in range(len(fusions)):
            if iou(bboxes[i], fusions[j]) > iou_threshold:
                lists[j].append(bboxes[i])
                confidences[j].append(scores[i])
                fusions[j] = (
                    sum([l[0] * c for l, c in zip(lists[j], confidences[j])]) / sum(confidences[j]),
                    sum([l[1] * c for l, c in zip(lists[j], confidences[j])]) / sum(confidences[j]),
                    sum([l[2] * c for l, c in zip(lists[j], confidences[j])]) / sum(confidences[j]),
                    sum([l[3] * c for l, c in zip(lists[j], confidences[j])]) / sum(confidences[j]),
                )
                
                new_fusion = False
                break

        if new_fusion:
            lists.append([bboxes[i]])
            confidences.append([scores[i]])
            fusions.append(bboxes[i])
            
        print(lists, fusions, confidences)
            
    confidences = [(sum(c) / len(c)) * (min(n, len(c)) / n) for c in confidences]
    
    return fusions, confidences
    
print(wbf([(10, 10, 20, 20), (15, 10, 25, 20), (15, 15, 25, 25), (30, 30, 40, 40)], [0.8, 0.7, 0.9, 0.5], 0.1, 3))
# ([(13.333333333333332, 11.874999999999998, 23.33333333333333, 21.874999999999996), (30, 30, 40, 40)], [0.8000000000000002, 0.16666666666666666])

使い分け

ではこれらアルゴリズムをどう使い分けるべきかについてです。最終的にはメトリクスを見ながら適切なものを選ぶべきかと思いますが、大まかにはこういった使い分けになるかと思います。

  • 単一モデルの精度を高めたい ->
    • 同一物体に対する誤検出が多い -> NMS (あるいは、IoU閾値を下げる or スコア閾値を上げる)
    • 同一画像内にたくさんの物体が含まれており、重複による未検出が多い -> Soft-NMS (あるいは、IoU閾値を上げる or スコア閾値を下げる)
    • 変形や動きが大きい物体が含まれており、1つのBBoxで捉えきれずにIoUが低い -> NMW
  • 精度がある程度高いモデルを複数組み合わせて、より高い精度を達成したい -> WBF

まとめ

物体検出で重なったBBoxを除去・集約するアルゴリズムについて整理しました。


  1. [1311.2524] Rich feature hierarchies for accurate object detection and semantic segmentation (初出の論文は別かも…?)

  2. [1704.04503] Soft-NMS -- Improving Object Detection With One Line of Code

  3. Huajun Zhou, Zechao Li, Chengcheng Ning, and Jinhui Tang. Cad: Scale invariant framework for real-time object detection. In Proceedings of the IEEE International Conference on Computer Vision, pages 760–768, 2017.

  4. [1910.13302] Weighted Boxes Fusion: ensembling boxes for object detection models

Amazon EMRのステップを使ってPySparkバッチアプリケーションを実装する

前回に引き続きEMRについてです。今回はEMRのステップを用いてPySparkのバッチアプリケーションを実装していきます。

ohke.hateblo.jp

EMRのステップ

前回は、JupyterノートブックからSpark環境へジョブをリクエストしていました。これは分析やモデリングの過程のアドホックなユースケースを想定していました。 これをバッチアプリケーションとしてプロダクション環境へそのまま持っていくと、いくつか不都合が発生します。

  • 前回の構成は共用を想定したクラスタのため、巨大なジョブの場合はリソース不足に陥りやすい
    • 他のジョブで忙しくしていると完了も遅れる
  • 専用のクラスタを立てる構成でも、起動させ続けてしまうと、ほとんどの時間でジョブは実行されないためコストパフォーマンスが悪い
    • 利用都度、クラスタを作成・終了をすることもできるが、ジョブのステータスを監視するのは面倒
  • Livyでジョブの実行をリクエストしていたが、インタラクティブに操作するわけではないので余分

EMRではこういったユースケースに対応する機能として、ステップが提供されています。ステップを使うと クラスタの構築 -> ジョブの実行 (1つ以上) -> クラスタの削除 という一連のフローをシンプルに実現できます。

準備

AWS CLIを使ってステップを実行する前に、いくつか準備必要です。

EMR操作権限の付与

AWS CLIを実行するロールに、EMRの操作権限が付与されている必要があります。
予めAmazonElasticMapReduceFullAccessポリシーを付与しておきます。

設定ファイルの作成

前回同様Python 3.6環境を使うようにSparkを設定する必要があるため、config.jsonファイルとしてローカルに作成しておきます。

[{
    "Classification": "spark-env",
    "Configurations": [{
        "Classification": "export",
        "Properties": {
            "PYSPARK_PYTHON": "/usr/bin/python3"
        }
    }]
}]

スクリプトのアップロード

前回作成したMovieLens 25mのPySparkレコメンドのコードをmovielens.pyとして作成し、S3にアップロード (ここでは s3://emr-temporary/steps/movielens.py ) しておきます。

ほぼ前回と同じですが、SparkSessionの取得を明示的に記述している点 (★) のみ修正しています (Sparkmagicでは裏でこれをやってくれていました) 。

from pyspark.context import SparkContext
from pyspark.sql.session import SparkSession
from pyspark.sql.types import *
from pyspark.ml.recommendation import ALS

# SparkSessionの取得 (★)
sc = SparkContext.getOrCreate()
spark = SparkSession(sc)

# スキーマを定義
schema = StructType([
    StructField("user", LongType(), True),
    StructField("item", LongType(), True),
    StructField("rating", FloatType(), True)
])

# sparkのDataFrameを作成
ratings = spark.read.csv("s3://emr-temporary/input/ratings.csv", schema=schema, header=False, sep=",")
# 欠損値を含む行を削除
ratings = ratings.dropna("any")

# モデルの作成
als = ALS(
    rank=20, maxIter=10, regParam=0.1, userCol="user", itemCol="item", ratingCol="rating", seed=0
)

# 学習
model = als.fit(ratings)

# 全ユーザのトップ10を予測
predicts = model.recommendForAllUsers(
    numItems=10
).select(
    "user", "recommendations.item", "recommendations.rating"
)

# 結果の出力
predicts.write.json("s3://emr-temporary/output/results")

ステップの実行

ではAWS CLIからSparkジョブをステップ実行します。

以下のようにemr create-clusterサブコマンドを使います。--stepsオプションにステップ実行の定義を記述します。

  • --stepsのJarにはcommand-runner.jar、引数 (Args) にアップロードしたスクリプトをそれぞれ指定
    • 実際に実行されるコマンドは hadoop jar /var/lib/aws/emr/step-runner/hadoop-jars/command-runner.jar spark-submit s3://emr-temporary/steps/movielens.py となります
  • --configurationsにローカルのconfig.jsonのファイルパスを指定
  • -- auto-terminateとすることで、実行後にクラスタが削除されます
$ aws emr create-cluster --name "movielens-cluter" --release-label emr-5.29.0 \
    --applications Name=Spark --instance-type m5.xlarge --instance-count 3 \
    --auto-terminate --ec2-attributes KeyName=mykey --use-default-roles 
    --configurations file://./config.json \
    --log-uri s3://aws-logs-000000000000-ap-northeast-1/elasticmapreduce/ \
    --steps Type=Spark,Name="movielens",Jar="command-runner.jar",Args=[s3://emr-temporary/steps/movielens.py],ActionOnFailure=CONTINUE
{
    "ClusterId": "j-K7TZ7758JFFT",
    "ClusterArn": "arn:aws:elasticmapreduce:ap-northeast-1:000000000000:cluster/j-2OLL9SP1TA1KW"
}

ちなみに複数のステップを連ねる場合、以下のように記述します。

-- steps \
   Type=Spark,Name="step1",Jar="command-runner.jar",Args=[s3://some/step1.py],ActionOnFailure=CONTINUE \
   Type=Spark,Name="step2",Jar="command-runner.jar",Args=[s3://some/step2.py],ActionOnFailure=CONTINUE \
   ...

このコマンドでクラスタの構築 -> ステップの実行 -> クラスタの削除まで行われます。

f:id:ohke:20200420133814p:plain

実行に失敗した場合は以下のようになります。--log-uriオプションで出力先を指定していると、ログファイルも確認できるようになります。

f:id:ohke:20200420134046p:plain

まとめ

EMRのステップを用いたバッチアプリケーションの実装方法について整理しました。これにCloudwatchやLambdaなどと組み合わせることで、様々なイベントをトリガとして分散処理を走らせるといったことが可能になりますね。

CIに組み込んで利用する場合、適宜検討しないといけないこともありますので、そのあたりは要件を勘案しながら設計する必要があります。

  • スクリプトファイルのバージョン指定をどうやって行うべきか (1つのファイルを参照してデプロイ時に上書きする、複数バージョンのファイルをアップロードしておいてデプロイ時にコマンドライン引数を切り替えるか、など)
  • パラメータ (実行時引数) はどうやって渡すべきか (コマンドライン引数で渡す、入力ファイルに含めて渡す、など)