「二つの頭を持つ分類器の使用例」

「美容とファッション分野における分類器の活用事例」

写真:UnsplashのVincent van Zalingeによる

アイデア

コンピュータビジョンのタスクに関する実際のケースについて話しましょう。一見すると、分類問題は可能な限り簡単ですし、それは事実です。しかし、実際の世界では、モデルの速度、サイズ、モバイルでの実行能力などの多くの制約が存在することがよくあります。さらに、複数のタスクがある場合、各タスクに個別のモデルを持つのは最善のアイデアではありません。システムのアーキテクチャを最適化し、より少ないモデルを使用できる場合は、少ないモデルを使用すべきです。ただし、正確さを失いたくはないですよね?全ての制約と最適化を考慮すると、タスクはより複雑になります。視覚的には似ていないにもかかわらず、いくつかのクラスを持つ分類問題の例を示したいと思います。

まずは、シンプルなタスクから始めましょう。画像が実際の紙の文書か、画面に文書が表示されている画像かを分類するとします。タブレット/スマートフォンや大型モニタなどですね。

実際の文書
画面

これはかなりシンプルです。データセットから始めて、それを収集して、代表的でクリーンで十分に大きいものにします。その後、制約(速度、正確さ、エクスポート可能性)に合ったモデルを選び、不均衡なデータに注意を払いつつ、通常のトレーニングパイプラインを使用します。それによりかなり良い結果が得られるはずです。

しかし、次に、新しい機能を追加して、モデルが入力が文書の画像か、文書でないもの(チップや缶、マーケティング素材など)かを分類できるようにする必要があるとしましょう。このタスクは元のタスクほど重要ではなく、それほど難しいものではありません。

文書でないもの

こちらがデータセットの構造です:

dataset/├── documents/│   ├── img_1.jpg|   ...│   └── img_100.jpg├── screens/│   ├── img_1.jpg|   ...│   └── img_100.jpg├── not a documents/│   ├── img_1.jpg│   ...│   └── img_100.jpg├── train.csv├── val.csv└── test.csv

およびcsvファイルの構造:

documents/img_1.jpg      | 0not a document/img_1.jpg | 1screens/img_1.jpg        | 2...

最初の列には画像への相対パス、2番目の列にはクラスIDが含まれています。次に、このタスクを解決するための2つのアプローチについて話しましょう。

3つの出力ニューロンアプローチ(シンプル)

最適なシステムアーキテクチャを持つために、小さなタスクごとに再び2値分類器の新しいモデルを持つつもりはありません。最初に思い浮かぶアイデアは、元のモデルに(文書ではないクラス)を第3のクラスとして追加することです。つまり、’文書’、’画面’、’非文書’のようなクラスができます。これは有望な選択肢ですが、これらのタスクの重要度が等しくない場合や、視覚的にこれらのクラスが似ていない場合に、分類レイヤーで異なる特徴が抽出されることを望む場合もあります。また、元のタスクの正確性を失わないことも非常に重要です。

2つのヘッドと二項分類のアプローチ(カスタム)

別のアプローチは、主に1つのバックボーンを使用し、各タスクに対して1つのヘッドと二項分類を使用することです。これにより、2つのタスク用の1つのモデルが得られ、各タスクが分離され、各タスクに対して多くの制御が可能となります。

実際のスピードはほとんど影響を受けません(3060の1つの画像で推論が約5-7%遅くなりました)、モデルのサイズが少し大きくなります(私の場合、TFLliteにエクスポートした後、500kbから700kbになりました)。また、最初のヘッドの損失の重みを2番目のヘッドの損失のN倍にすると便利です。これにより、フォーカスが最初(メイン)のタスクにあることが保証され、それに対する精度の低下が少なくなります。

以下はその見た目です:

Two headed output

このタスクではSuffleNetV2を使用し、アーキテクチャを最後の畳み込み層から2つのパーツに分割しています。各ヘッドには、分類のための独自の最後の畳み込み層、グローバルプーリング層、完全接続層があります。

コードの例

モデルのアーキテクチャを理解したので、トレーニングパイプラインのいくつかの変更が必要であることは明らかです。データセットジェネレータから始めましょう。データセットとデータローダのコードを書く際に、各反復ごとに1つの画像と2つのラベルを返す必要があります。最初のラベルは最初のヘッドに使用され、2番目のラベルは2番目のヘッドに使用されます。以下にコードの例を示します:

class CustomDataset(Dataset):    def __init__(        self,        root_path: Path,        split: pd.DataFrame,        train_mode: bool,    ) -> None:        self.root_path = root_path        self.split = split        self.img_size = (256, 256)        self.norm = ([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])        self._init_augs(train_mode)    def _init_augs(self, train_mode: bool) -> None:        if train_mode:            self.transform = transforms.Compose(                [                    transforms.Resize(self.img_size),                    transforms.Lambda(self._convert_rgb),                    transforms.RandomRotation(10),                    transforms.ToTensor(),                    transforms.Normalize(*self.norm),                ]            )        else:            self.transform = transforms.Compose(                [                    transforms.Resize(self.img_size),                    transforms.Lambda(self._convert_rgb),                    transforms.ToTensor(),                    transforms.Normalize(*self.norm),                ]            )    def _convert_rgb(self, x: torch.Tensor) -> torch.Tensor:        return x.convert("RGB")    def __getitem__(self, idx: int) -> Tuple[torch.Tensor, int, int]:        image_path, label = self.split.iloc[idx]        image = Image.open(self.root_path / image_path)        image.draft("RGB", self.img_size)        image = ImageOps.exif_transpose(image)  # fix rotation        image = self.transform(image)        label_lcd = int(label == 2)        label_other = int(label == 1)        return image, label_lcd, label_other    def __len__(self) -> int:        return len(self.split)

我々は__getitem__に興味があります。ここで、labellabel_lcdlabel_otherに分割します(2つのヘッド)。label_lcdは「画面」に対して1であり、その他の場合は0です。label_otherは「ドキュメントでない」に対して1であり、その他の場合は0です。

アーキテクチャに対しては以下のようになります:

class CustomShuffleNet(nn.Module):    def __init__(self, n_outputs_1: int, n_outputs_2: int) -> None:        super(CustomShuffleNet, self).__init__()        self.base_model = models.shufflenet_v2_x0_5(            weights=models.ShuffleNet_V2_X0_5_Weights.DEFAULT        )        # ヘッドの畳み込み層を作成        self.head1_conv = self._create_head_conv()        self.head2_conv = self._create_head_conv()        # 両方のヘッド用の完全接続層を作成        in_features = self.base_model.fc.in_features        del self.base_model.fc        self.fc1 = nn.Linear(in_features, n_outputs_1)        self.fc2 = nn.Linear(in_features, n_outputs_2)    def _create_head_conv(self) -> nn.Module:        return nn.Sequential(            nn.Conv2d(192, 1024, kernel_size=1, stride=1, bias=False),            nn.BatchNorm2d(1024),            nn.ReLU(inplace=True),        )    def forward(self, x: torch.Tensor) -> torch.Tensor:        x = self.base_model.conv1(x)        x = self.base_model.maxpool(x)        x = self.base_model.stage2(x)        x = self.base_model.stage3(x)        x = self.base_model.stage4(x)        # 各ヘッドの独立した畳み込みを通す        x1 = self.head1_conv(x)        x1 = x1.mean([2, 3])  # first headのためのglobalpool        x2 = self.head2_conv(x)        x2 = x2.mean([2, 3])  # second headのためのglobalpool        out1 = self.fc1(x1)        out2 = self.fc2(x2)        return out1, out2

最後のConvレイヤー(含まれる)から、アーキテクチャは2つの並列ヘッドに分割されます。今モデルには2つの出力があります。

トレーニングループ:

def train(    train_loader: DataLoader,    val_loader: DataLoader,    device: str,    model: nn.Module,    loss_func: nn.Module,    optimizer: torch.optim.Optimizer,    scheduler: torch.optim.lr_scheduler,    epochs: int,    path_to_save: Path,) -> None:    best_metric = 0    wandb.watch(model, log_freq=100)    for epoch in range(1, epochs + 1):        model.train()        with tqdm(train_loader, unit="batch") as tepoch:            for inputs, labels_1, labels_2 in tepoch:                inputs, labels_1, labels_2 = (                    inputs.to(device),                    labels_1.to(device),                    labels_2.to(device),                )                tepoch.set_description(f"Epoch {epoch}/{epochs}")                optimizer.zero_grad()                outputs_1, outputs_2 = model(inputs)                loss_1 = loss_func(outputs_1, labels_1)                loss_2 = loss_func(outputs_2, labels_2)                loss = 2 * loss_1 + loss_2                loss.backward()                optimizer.step()                tepoch.set_postfix(loss=loss.item())        metrics = evaluate(            test_loader=val_loader, model=model, device=device, mode="val"        )        if scheduler is not None:            scheduler.step()        if metrics["f1_1"] > best_metric:            best_metric = metrics["f1_1"]            print("新しいモデルを保存中...")            path_to_save.parent.mkdir(parents=True, exist_ok=True)            torch.save(model.state_dict(), path_to_save)        wandb_logger(loss, metrics, mode="val")

データセットからimagelabel_1label_2を取得し、画像(実際にはバッチ)をモデルに通し、それから損失を2回計算します(各ヘッドの出力について1回ずつ)。メインの損失を2倍にすることで、メインのヘッドに集中します。もちろん、2つのヘッドを持つモデルに合わせてメトリクスの計算などを変更する必要があります(リポジトリで完全な例を見つけることができます)。また、重要なことは、’メイン’ヘッドから得られるメトリクスに基づいてモデルを保存することです。

結果

トレーニングパイプラインでのF1スコアの比較は意味がありません。それらは3つのクラスと2つのクラスに対して計算され、それぞれのメトリクスに関心があります。したがって、特定のテストデータセットを使用し、両方のモデルを実行して、ドキュメント/スクリーンとドキュメント/非ドキュメントのタスクごとに精度と再現率を比較しました。

両方のモデルは256×256の入力サイズを使用していますが、推論時間がほぼ同じであったため、320×320の入力サイズと3つの出力ニューロン方式のバージョンも追加しました。2つのアプローチの結果の違いを比較するのは興味深かったです。2番目のタスクは両方のアプローチで完全に同じ結果になりましたが(私の場合、モデルにとっては簡単なタスクです)、メインのタスクには違いがあります。

+----------------------------+-----------+-----------+--------------+|      モデル (img size)      | 精度      |  再現率   | レイテンシ (s)*  |+----------------------------+-----------+-----------+--------------+| 3つの出力ニューロン (256) |     0.993 | 0.855     |        0.027 || 3つの出力ニューロン (320) |       1.0 | 0.846     |        0.029 || 2つのヘッド (256)          |       1.0 | 0.873     |        0.029 |+----------------------------+-----------+-----------+--------------+

レイテンシ (s)* — データの変換とソフトマックスを含む1つの画像の平均推論時間。

必要なブーストがここにあります!二つのヘッドモデルは、副次的なタスクのスコアは同じですが、メインのタスクでは同じかより高い精度を持ち、再現率も高くなります。これは実世界のデータ(トレーニング/検証/テストスプリットではない)です。

注意:このタスクでは重要なタスク(ドキュメント/スクリーン)だけでなく、精度再現率よりも重要です。したがって、‘320の入力サイズの出力ニューロン’アプローチが勝ちます。しかし最終的には、二つのヘッドモデルが同じ推論時間でより良いスコアを収めます。

もう一つ重要なことがあります。このアプローチは、具体的なモデルとデータにおいて私の場合にはより良い結果をもたらしました。他のタスクでも一部ではうまく機能しましたが、常に仮説を立てて実験を行い、最良のアプローチを見つけるためにその結果をテストすることが重要です。そのために、設定や実験結果を保存するためのツールの使用をおすすめします。私はここでは設定にはHydraを、実験の追跡にはWandbを使用しました。

まとめると

  • 分類は簡単ですが、現実の制約条件が加わると難しくなります
  • サブタスクを最適化し、大きなタスクごとにK個のモデルを作成しないようにしましょう
  • モデルと訓練パイプラインをカスタマイズして、より良い制御を行いましょう
  • 仮説をテストし、実験を行い、結果を保存しましょう(Hydra、Wandbなど)

以上が基本的な内容です。コードの完全な例はこちらでご覧いただけますので、自分自身でテストを実行してみてください。ご質問やご提案がある場合は、どうぞお気軽にご連絡ください

We will continue to update VoAGI; if you have any questions or suggestions, please contact us!

Share:

Was this article helpful?

93 out of 132 found this helpful

Discover more