コンピューター ビジョンのディープラーニングでよくある 8 つのバグをまとめました。誰もが多かれ少なかれこれらのバグに遭遇したことがあると思います。問題を回避するのに役立つことを願っています。 [[285233]] 人間は不完全であり、ソフトウェアでは間違いを犯すことがよくあります。コードが機能しない、アプリがクラッシュするなど、これらのエラーは簡単に見つけられる場合もあります。しかし、いくつかのバグは隠れており、さらに危険になっています。 このタイプのバグは、不確実性のためにディープラーニングの問題を解決するときに簡単に発生します。Web アプリケーションがリクエストを正しくルーティングしているかどうかを確認するのは簡単ですが、勾配降下法の手順が正しいかどうかを確認するのは簡単ではありません。しかし、多くの間違いは回避できます。 過去 2 年間のコンピューター ビジョンの仕事で私が見たり、犯したりした間違いに関する経験をいくつか共有したいと思います。私はこのトピックについて(カンファレンスで)講演しましたが、その後多くの人から「私も同じバグにたくさん遭遇しました」と言われました。この記事が、皆さんがこれらの問題の少なくとも一部を回避するのに役立つことを願っています。 1. 画像とキーポイントを反転します。 キーポイント検出の問題を考えてみましょう。データは、画像とキーポイント タプルのシーケンスのペアのように見えます。各キーポイントは x 座標と y 座標のペアです。 このデータに基本的な拡張を加えてみましょう。 - def flip_img_and_keypoints(img: np.ndarray, kpts:シーケンス[シーケンス[ int ]]):
- 画像 = np.fliplr(画像)
- h、w、*_ = 画像の形状
- kpts = [(y, w - x) 、 y、xはkpts内]
- img、kptsを返す
正しいように見えますね?視覚化してみましょう。 - 画像 = np.ones((10, 10), dtype=np.float32)
- kpts = [(0, 1), (2, 2)]
- image_flipped、kpts_flipped = flip_img_and_keypoints(画像、kpts)
- img1 = 画像.コピー()
- y、xは kptsの場合:
- 画像1[y, x] = 0
- img2 = image_flipped.copy()
- kpts_flippedのy、xについて:
- 画像2[y, x] = 0
-
- _ = plt.imshow(np.hstack((img1, img2)))
非対称で変ですね!極端な値をチェックしたらどうなるでしょうか? - 画像 = np.ones((10, 10), dtype=np.float32)
いいえ!これは典型的な「一つ間違えた」ミスです。正しいコードは次のとおりです。 - def flip_img_and_keypoints(img: np.ndarray, kpts:シーケンス[シーケンス[ int ]]):
- 画像 = np.fliplr(画像)
- h、w、*_ = 画像の形状
- kpts = [(y, w - x - 1) y、xはkptsの場合]
- img、kptsを返す
この問題は視覚化によって発見されましたが、「x = 0」ポイントを使用した単体テストも役立ちます。面白い事実: 1 つのチームの 3 人 (私を含む) がそれぞれほぼ同じ間違いを犯しました。 2. 重要なポイントに関連する質問を続けます 上記の機能が修正された後でも、危険性は依然として存在します。今では、単なるコードではなく、セマンティクスが重要になっています。 画像を強調するには 2 つの手のひらが必要であるとします。安全そうです: 手は左右反転しています。
しかし、待ってください。キーポイントのセマンティクスについてはあまりわかっていません。このキーポイントが次のことを意味する場合: - kpts = [
- (20, 20)、#左小指
- (20, 200)、#右小指
- ...
- ]
つまり、拡張によって実際にセマンティクスが変わります。左が右になり、右が左になりますが、配列内のキーポイント インデックスは交換されません。トレーニングに多くのノイズが導入され、メトリックが悪化します。 私たちは教訓を学ぶべきだ: - 拡張機能やその他の高度な機能を適用する前に、データ構造とセマンティクスを理解して考慮する
- 実験をアトミックに保ちます。小さな変更 (新しい変換など) を追加し、そのパフォーマンスを確認し、スコアが向上した場合にのみ追加します。
3. 独自の損失関数を書く セマンティックセグメンテーションの問題に精通している人は、IoU メトリックを知っているかもしれません。残念ながら、SGD を使用して直接最適化することはできないため、微分可能な損失関数を使用して近似するのが一般的なアプローチです。 - iou_continuous_loss(y_pred, y_true)を定義します。
- eps = 1e-6
- _sum(x)を定義します:
- x.sum (-1).sum ( -1 )を返す
- 分子 = (_sum(y_true * y_pred) + eps)
- 分母 = (_sum(y_true ** 2) + _sum(y_pred ** 2)
- - _sum(y_true * y_pred) + eps)
- (分子 / 分母).mean()を返します。
良さそうです。ちょっと確認してみましょう: - [3]において: ones = np.ones((1, 3, 10, 10))
- ...: x1 = iou_continuous_loss(ones * 0.01, ones)
- ...: x2 = iou_continuous_loss(ones * 0.99, ones)
- [4]では:x1、x2
- アウト[4]: (0.010099999897990103, 0.9998990001020204)
x1 では、真実とはまったく異なるものに対する損失を計算しましたが、x2 は真実に非常に近いものの結果です。予測が間違っていたため、x1 は大きくなり、x2 はゼロに近くなると予想されます。どうしたの? 上記の関数はメトリックの良い近似値です。メトリックは損失ではありません。通常、(この場合も含め) メトリックが高いほど良いです。損失を最小限に抑えるために SGD を使用する場合は、逆のことを使用する必要があります。 - iou_continuous(y_pred, y_true)を定義します。
- eps = 1e-6
- _sum(x)を定義します:
- x.sum (-1).sum ( -1 )を返す
- 分子 = (_sum(y_true * y_pred) + eps)
- 分母 = (_sum(y_true ** 2) + _sum(y_pred ** 2)
- - _sum(y_true * y_pred) + eps)
- (分子 / 分母).mean()を返します。
- iou_continuous_loss(y_pred, y_true)を定義します。
- 1を返す- iou_continuous(y_pred, y_true)
これらの問題は、次の 2 つの観点から特定できます。 - 損失の方向を確認する単体テストを記述します。つまり、真実に近いほど損失が低くなるという期待を形式化します。
- 健全性チェックを実行して、モデルが単一のバッチで過剰適合していないかどうかを確認します。
4. Pytorchを使うとき 事前にトレーニングされたモデルがあると仮定して、推論を開始します。 - ceevee.baseからAbstractPredictor をインポートします
- クラス MySuperPredictor(AbstractPredictor):
- デフ__init__(self,
- weights_path: 文字列、
- ):
- スーパー().__init__()
- self.model = self._load_model(weights_path = weights_path) です。
- defプロセス(self, x, *kw):
- torch.no_grad()の場合:
- res = 自己.モデル(x)
- 戻り値
- @静的メソッド
- def _load_model(重みパス):
- モデル = モデルクラス()
- 重み = torch.load (weights_path、map_location = 'cpu' )
- model.load_state_dict(重み)
- リターンモデル
このコードは正しいでしょうか? おそらくそうです! これは一部のモデルでは機能します。たとえば、torch.nn.BatchNorm2d など、モデルにドロップアウト レイヤーまたはノルム レイヤーがない場合などです。または、モデルが各画像に実際のノルム統計を使用することを要求する場合 (たとえば、多くの pix2pix ベースのアーキテクチャではそれが必要です)。 しかし、ほとんどのコンピューター ビジョン アプリケーションでは、評価モードへの切り替えという重要な点がコードで見落とされています。 動的 PyTorch グラフを静的 PyTorch グラフに変換しようとすると、この問題は簡単に識別できます。この変換には torch.jit が使用されます。 - [3]では: model = nn.Sequential(
- ...: nn.Linear(10, 10)、
- ...: nn.ドロップアウト(.5)
- ...: )
- ...:
- ...: traced_model = torch.jit.trace(モデル、torch.rand(10))
- /Users/Arseny/.pyenv/versions/3.6.6/lib/python3.6/site-packages/torch/jit/__init__.py:914: TracerWarning: Trace には非決定的なノードがありました。モデルで.eval() を呼び出すのを忘れましたか? ノード:
- %12 : Float (10) = aten::dropout(%input, %10, %11)、スコープ: Sequential/Dropout[1] # /Users/Arseny/.pyenv/versions/3.6.6/lib/python3.6/site-packages/torch/nn/ functional.py:806:0
- これにより、トレースチェックでエラーが発生する可能性があります。トレースチェックを無効にするには、check_trace= Falseを渡します。 torch.jit.trace()へ
- check_tolerance、_force_outplace、 True 、_module_class)
- /Users/Arseny/.pyenv/versions/3.6.6/lib/python3.6/site-packages/torch/jit/__init__.py:914: TracerWarning:トレースされた関数の出力番号 1 が対応する出力と一致しません Python関数の詳細なエラー:
- 許容範囲外rtol=1e-05 atol=1e-05入力[5] (0.0 vs. 0.5454154014587402)およびその他5箇所 (60.00%)
- check_tolerance、_force_outplace、 True 、_module_class)
簡単な修正: - [4]では:model = nn.Sequential(
- ...: nn.Linear(10, 10)、
- ...: nn.ドロップアウト(.5)
- ...: )
- ...:
- ...: traced_model = torch.jit.trace(model.eval(), torch.rand(10))
- # 警告はもうありません!
この場合、torch.jit.trace はモデルを複数回実行し、結果を比較します。ここでの区別は疑わしい。 ただし、torch.jit.trace はここでは万能薬ではありません。これは知っておいて覚えておくべきニュアンスです。 5. コピー&ペーストの問題 多くのものはペアになっています: トレーニングと検証、幅と高さ、緯度と経度など... - def make_dataloaders(train_cfg、val_cfg、batch_size):
- トレーニング = Dataset.from_config(train_cfg)
- val = Dataset.from_config(val_cfg)
- shared_params = { 'batch_size' : batch_size、 'shuffle' : True 、 'num_workers' : cpu_count()}
- トレーニング = DataLoader(トレーニング、**shared_params)
- val = DataLoader(train, **shared_params)
- 帰りの電車、val
愚かなミスを犯したのは私だけではありませんでした。たとえば、非常に人気のあるアルバム ライブラリにも同様のバージョンがあります。 - # https://github.com/albu/albumentations/blob/0.3.0/albumentations/augmentations/transformations.py
- def apply_to_keypoint(self、キーポイント、crop_height=0、crop_width=0、h_start=0、w_start=0、 rows =0、cols=0、**params):
- キーポイント = F.keypoint_random_crop(キーポイント、クロップ高さ、クロップ幅、h_start、w_start、行数、列数)
- scale_x = 自己幅 / クロップ高さ
- scale_y = self.height / crop_height
- キーポイント = F.keypoint_scale(キーポイント、スケールx、スケールy)
- キーポイントを返す
心配しないでください。修正されました。 回避するにはどうすればよいでしょうか? コードをコピーして貼り付けるのではなく、コピーして貼り付ける必要がない方法でコードを記述するようにしてください。 - データセット = []
- data_a = get_dataset(MyDataset(config[ 'dataset_a' ]), config[ 'shared_param' ], param_a)
- データセット.append(data_a)
- data_b = get_dataset(MyDataset(config[ 'dataset_b' ]), config[ 'shared_param' ], param_b)
- データセット.append(data_b)
- データセット = []
- のために 名前、zip内のパラメータ(( 'dataset_a' 、 'dataset_b' )、
- (パラメータa、パラメータb)、
- ):
- データセットの追加(get_dataset(MyDataset(config[ name ]), config[ 'shared_param' ], param))
6. 適切なデータ型 新しい拡張機能を書いてみましょう: - def add_noise(img: np.ndarray) -> np.ndarray:
- マスク = np.random.rand(*img.shape) + .5
- img = img.astype( 'float32' ) * マスク
- img.astype( 'uint8' )を返す
画像が変更されました。これは私たちが予想していた通りでしょうか? まあ、あまりにも変化しすぎているのかもしれません。 ここでは、float32 を uint8 に変換するという危険な操作が行われます。オーバーフローが発生する可能性があります: - def add_noise(img: np.ndarray) -> np.ndarray:
- マスク = np.random.rand(*img.shape) + .5
- img = img.astype( 'float32' ) * マスク
- np.clip(img, 0, 255).astype( 'uint8' )を返します。
- img = add_noise(cv2.imread( 'two_hands.jpg' )[:, :, ::-1])
- _ = plt.imshow(画像)
見た目がずっと良くなりましたね? ちなみに、この問題を回避する方法もあります。車輪の再発明はせず、拡張コードを最初から書かず、既存の拡張機能である albumentations.augmentations.transforms.GaussNoise を使用します。 私はかつて、同じ原因による別のバグに取り組んだことがあります。 - raw_mask = cv2.imread( 'mask_small.png' )
- マスク = raw_mask.astype( 'float32' ) / 255
- マスク = cv2.resize(マスク、(64, 64)、補間=cv2.INTER_LINEAR)
- マスク = cv2.resize(マスク、(128, 128)、補間=cv2.INTER_CUBIC)
- マスク = (マスク * 255).astype( 'uint8' )
- _ = plt.imshow(np.hstack((raw_mask, マスク)))
ここで何が間違っていたのでしょうか? まず第一に、キュービック補間を使用してマスクのサイズを変更するのは悪い考えです。 float32 から uint8 への変換でも同じ問題が発生します。3 次補間により入力よりも大きな値が出力される可能性があり、オーバーフローが発生します。
私は視覚化を行っていたときにこの問題を発見しました。トレーニング ループ全体にアサーションを配置することもお勧めします。 7. スペルミス 完全な畳み込みネットワーク (セマンティックセグメンテーション問題など) と巨大な画像に対して推論を実行する必要があるとします。画像が非常に大きいため、GPU に収まらない場合があります。医療画像や衛星画像である可能性があります。 この場合、画像をグリッドに分割し、各パッチに対して個別に推論を実行し、最後にそれらを結合することができます。さらに、いくつかの予測クロスオーバーは境界付近のアーティファクトを滑らかにするのに役立つ場合があります。 - tqdmからtqdm をインポート
- クラス GridPredictor:
- 「」 「 」
- このクラスは大きな画像のセグメンテーションマスクを予測するために使用できます
- GPUメモリ制限がある場合
- 「」 「 」
- def __init__(self, 予測子: AbstractPredictor,サイズ: int , ストライド: Optional[ int ] = None):
- self.predictor = 予測子
- 自己.size =サイズ
- self.stride = stride (strideが 他にはない サイズ// 2
- def __call__(self, x: np.ndarray):
- h, w, _ = x.shape
- マスク = np.zeros((h, w, 1), dtype= 'float32' )
- 重み = mask.copy()
- iがtqdm(range(0, h - 1, self.stride))の場合:
- jが範囲(0, w - 1, self.stride)内にある場合:
- a、b、c、d = i、最小値(h、i + 自己サイズ)、j、最小値(w、j + 自己サイズ)
- パッチ = x[a:b, c:d, :]
- マスク[a:b, c:d, :] += np.expand_dims(self.predictor(patch), -1)
- 重み[a:b, c:d, :] = 1
- マスク/重みを返す
シンボルのタイプミスがありますが、コード スニペットは十分に大きいため、簡単に見つけることができます。コードを見ただけではすぐに識別できるとは思えません。ただし、コードが正しいかどうかを確認するのは簡単です。 - クラス Model(nn.Module):
- def forward (self, x):
- x.mean(axis=-1)を返します
- モデル = モデル()
- grid_predictor = GridPredictor(モデル、サイズ= 128、ストライド = 64)
- simple_pred = np.expand_dims(モデル(画像), -1)
- グリッド予測 = グリッド予測子(画像)
- np.testing.assert_allclose(シンプル_pred、グリッド_pred、atol=.001)
-
- AssertionError トレースバック (最新の呼び出しが最後)
- <ipython-input-24-a72034c717e9> は<module>にあります
- 9 グリッド予測 = グリッド予測子(画像)
- 10
-
- ~/.pyenv/versions/3.6.6/lib/python3.6/site-packages/numpy/testing/_private/utils.pyでassert_allclose(actual、desired、rtol、atol、equal_nan、err_msg、verbose)を実行します。
- 1513 ヘッダー = '許容値 rtol=%g、atol=%g と等しくありません' % (rtol、atol)
- 1514 assert_array_compare(比較、実際、望ましい、err_msg=str(err_msg)、
- -> 1515 verbose=詳細、header=ヘッダー、equal_nan=equal_nan)
- 1516
- 1517
- ~/.pyenv/versions/3.6.6/lib/python3.6/site-packages/numpy/testing/_private/utils.pyのassert_array_compare(comparison, x, y, err_msg, verbose, header, precision , equal_nan, equal_inf)
- 839 verbose=詳細、header=ヘッダー、
- 840 名前 = ( 'x' 、 'y' )、精度=精度)
-
- 842 ValueErrorを除く:
- 843 インポート トレースバック
- アサーションエラー:
- 許容値 rtol=1e-07、atol=0.001と等しくありません
- 不一致: 99.6%
- マックス 絶対差: 765。
- マックス 相対差: 0.75000001
- x: 配列([[[215.333333],
- [192.666667],
- [250. ]、...
- y: 配列([[[ 215.33333],
- [192.66667],
- [ 250. ]、...
__call__ メソッドの正しいバージョンは次のとおりです。 - def __call__(self, x: np.ndarray):
- h, w, _ = x.shape
- マスク = np.zeros((h, w, 1), dtype= 'float32' )
- 重み = mask.copy()
- iがtqdm(range(0, h - 1, self.stride))の場合:
- jが範囲(0, w - 1, self.stride)内にある場合:
- a、b、c、d = i、最小値(h、i + 自己サイズ)、j、最小値(w、j + 自己サイズ)
- パッチ = x[a:b, c:d, :]
- マスク[a:b, c:d, :] += np.expand_dims(self.predictor(patch), -1)
- 重み[a:b, c:d, :] += 1
- マスク/重みを返す
それでも問題がわからない場合は、線の太さ[a:b,c:d,:]+=1に注意してください。 8. イメージネット正規化 転移学習を行う必要がある場合は、通常、Imagenet をトレーニングするときと同じ方法で画像を正規化するのがよいでしょう。 すでに使い慣れている albumentations ライブラリを使用しましょう。 - アルバムからのインポート正規化
- ノルム = 正規化()
- 画像 = cv2.imread( 'img_small.jpg' )
- マスク = cv2.imread( 'mask_small.png' , cv2.IMREAD_GRAYSCALE)
- mask = np.expand_dims(mask, -1) # 形状 (64, 64) -> 形状 (64, 64, 1)
- normed = norm(画像=img、マスク=mask)
- img, mask = [normed[x] for x in [ 'image' , 'mask' ]]
- img_to_batch(x)を定義します。
- x = np.transpose(x, (2, 0, 1)).astype( 'float32' )
- torch.from_numpy(np.expand_dims(x, 0))を返します
- img、マスク = map(img_to_batch、(img、マスク))
- 基準 = F.binary_cross_entropy
ここで、ネットワークをトレーニングし、単一の画像にオーバーフィットさせます。前述したように、これは優れたデバッグ手法です。 - モデル_a = UNet(3, 1)
- オプティマイザー = torch.optim.Adam(model_a.parameters(), lr=1e-3)
- 損失 = []
- tqdm(range(20))内のtについて:
- 損失 = 基準(model_a(img), マスク)
- 損失.append(損失.item())
- オプティマイザ.zero_grad()
- 損失.後方()
- オプティマイザ.ステップ()
-
- _ = plt.plot(損失)
曲率は良好に見えますが、クロスエントロピー損失値が -300 というのは予想外です。何が問題ですか? 画像の正規化は正常に機能しますが、マスクは機能しません。手動で [0,1] にスケーリングする必要があります。 - モデルb = UNet(3, 1)
- オプティマイザー = torch.optim.Adam(model_b.parameters(), lr=1e-3)
- 損失 = []
- tqdm(range(20))内のtについて:
- 損失 = 基準(model_b(img), マスク / 255.)
- 損失.append(損失.item())
- オプティマイザ.zero_grad()
- 損失.後方()
- オプティマイザ.ステップ()
-
- _ = plt.plot(損失)
トレーニング ループの単純なランタイム アサーション (例: assertMask.max() <= 1) により、問題がすぐに検出されます。同様に、これは単体テストにもなります。 要約する - テストは必要
- ランタイムアサーションはトレーニング パイプラインで使用できます。
- 視覚化は一種の幸福である
- コピー&ペーストは呪い
- 特効薬というものはなく、機械学習エンジニアは常に注意しなければなりません(そうでなければ苦しむだけです)。
|