Table of Contents コンテンツテーブル 引用:ラベルは高価、画像は無料 Part 1 — Data Exploration: あなたが作業しているデータを理解する Part 2 — Preprocessing: Speaking the Model's Language モデルの言語 Part 3 — Feature Extraction: イメージを有意義な数字に変える Part 4 - Unsupervised Clustering: Discovering Structure in the Dark(監視されていない集団化:闇の中の構造を発見) 第5部 半監督トレーニング:コア実験 Part 6 — 何百万もの画像にスケーリングする:現実的なロードマップ 結論 ラベルは高価で、画像は無料です。 完璧な世界では、あなたのデータセットのすべての画像にはラベルが付いているでしょう。「欠陥」、「正常」、「クラック型A」、「クラッチ型B」しかし、現実世界では、ラベル化は残酷に高価です。 1 つの医療放射線学者は、1 時間あたり 50 回の脳スキャンをラベル化することができ ── 時価は 200 ユーロです。 産業品質検査官は、1 時間あたり 100 件の部分を注釈することができます。 ここに矛盾があります:企業はしばしば何百万もの画像(カメラ、センサー、ユーザーアップロード)を持っていますが、わずかな割合しかラベルを付けることはできません。 ラベルのない画像を捨てる代わりに、モデルを改善するために、小さなラベル付きセットと大きなラベルのないセットを組み合わせます。 semi-supervised learning あなたはすべての学生に試験を与えますが、5つの論文を評価するだけの時間を持っています。あなたは5つの論文を慎重に評価し、あなたはパターンに気付きます:多くを書いた学生は高いスコアを得る傾向があり、空の答えを残した学生は低いスコアを得る傾向があります。それらのパターンを使用して、あなたは他の25の論文のグレードを推定することができます。 それは半監督学習です:あなたはパターンを理解するためにいくつかのグレードされた論文(ラベル化されたデータ)を使用し、そのパターンを非グレードされたもの(ラベル化されていないデータ)に適用します。 この記事では、完全な半監督画像分類パイプラインをゼロから構築します. We will work through every step with detailed explanations as we go. ケーススタディ:金属表面の製造欠陥を検出する工場は鋼板を生産し、カメラは生産ラインから転がるときに各プレートを撮影します。ほとんどのプレートは正常で、いくつかの欠陥(グリップ、裂け、ピッティング、インクルシウム)があります。我々は1万枚の画像を持っていますが、200枚しかラベルされていません。 ケース研究: 一つの工場は鋼板を生産し、カメラは生産ラインから転がるときに各プレートを撮影します。ほとんどのプレートは正常で、いくつかは欠陥があります(グラス、裂け穴、ピッティング、包装)。 detecting manufacturing defects on metal surfaces. 半監督学習パイプラインの構築 何らかのコードに潜入する前に、パイプライン全体をマッピングしましょう. 最初に流れを理解すると、それぞれのステップがランダムではなく意図的に感じるようになります. しばらくこの図を勉強してください - それぞれのボックスは、私たちが実装するステップです: THE COMPLETE SEMI-SUPERVISED PIPELINE: [10,000 raw images] ──▶ [Exploration & Cleaning] │ ▼ [Preprocessing: resize, normalize, histogram equalization] │ ▼ [Feature Extraction: pretrained ResNet50 → 2048-dim embedding per image] │ ┌─────────┴──────────┐ │ │ ▼ ▼ [200 LABELED images] [9,800 UNLABELED images] │ │ │ ▼ │ [Clustering: K-Means, DBSCAN │ on embeddings → pseudo-labels] │ │ │ ▼ │ [WEAKLY labeled dataset] │ (cluster assignments) │ │ ▼ ▼ ┌────────────────────────────────────┐ │ SEMI-SUPERVISED TRAINING: │ │ 1. Pre-train CNN on weakly labeled │ │ 2. Fine-tune CNN on strongly labeled│ │ 3. Compare vs supervised-only │ └────────────────────────────────────┘ │ ▼ [Evaluation: F1, AUC-ROC, confusion matrix, comparison] The key insight: we will turn unlabeled images into ラベルなしの画像 ラベル化された画像(クラスタリングを通じて)、その近似の知識を使用して、最終モデルに頭を向けるようにしましょう。 approximately この記事を通して使用する辞書についても明確にしましょう、なぜなら、用語を混同することは非常に一般的な混乱の源です。 強くラベル化(または単に「ラベル化」):人間の専門家によって検証されたラベル付きの画像。 弱いラベル(または「偽のラベル」):ラベルがクラスタリングによって推測された画像。 Unlabelled: ラベルが全くない画像. This is their state before we cluster them. これは、私たちがそれらをクラスターする前にそれらの状態です。 埋め込み:プレトレーニングされたニューラルネットワークによって生成された画像のコンパクトな数値概要. Our main tool for making images comparable. 続きを読む: 続きを読む: 半監督学習概要 - scikit-learn Google Research 半監督学習 半監督学習概要 - scikit-learn Google Research 半監督学習 1. Data Exploration: あなたが作業しているデータを理解する 何故他のことをする前にデータを見る必要があるのか 画像データセットには、テーブルデータでは見つからないユニークなエラーモードがあります:午前3時にトレーニングループを崩壊させる破損したファイル、静かに画像を歪曲する不一致な解像度、間違ったカラーチャンネル(グレイスケールがRGBに隠されている)、および極端なクラス不均衡で、95%の画像が「正常」です。 黄金のルール: 何を扱っているのか見ていきましょう。 never trust data you haven't inspected. 1.1 — 画像のロードと数え方 当社のデータセットは、2つの主要なフォルダに構成されています:一つはラベル化された画像(「正常」と「欠陥」のサブフォルダに分けられ)、そして一つはラベル化されていない画像(サブフォルダはありません - 単に平らなコレクションです。 ファイル) .png import os import numpy as np import pandas as pd import matplotlib.pyplot as plt from PIL import Image from pathlib import Path data_dir = Path("data/metal_surfaces") labeled_dir = data_dir / "labeled" unlabeled_dir = data_dir / "unlabeled" 我々 使用 それは、どのオペレーティングシステムでもパスセプターを正しく処理しているので、ストレージコンカテネーションではなく、次のように計算します。 pathlib.Path labeled_files = list(labeled_dir.glob("**/*.png")) unlabeled_files = list(unlabeled_dir.glob("**/*.png")) print(f"Labeled images: {len(labeled_files)}") print(f"Unlabeled images: {len(unlabeled_files)}") print(f"Total: {len(labeled_files) + len(unlabeled_files)}") label_ratio = len(labeled_files) / (len(labeled_files) + len(unlabeled_files)) print(f"Label ratio: {label_ratio:.1%}") そのラベル比率は通常約2%です。当社のデータのわずか2%は専門家によって検証されたラベルを持っています。残りの98%は、私たちが無視する余裕がない金鉱であり、それが正確に半監督学習が利用するものです。 1.2 — 問題のスキャン:解像度、色、腐敗 次に、各画像を個別にチェックする必要があります。 1 つの破損したファイルは全体のトレーニングを実行する可能性があります。不一致な解像度は、あなたが慎重でない場合に画像を歪曲します。 各画像を開いてその属性を記録する小さな関数を書きます。 def get_image_info(filepath): """ Try to open an image and record its properties. If the file is corrupted, Pillow will throw an exception. """ try: img = Image.open(filepath) return { "path": str(filepath), "width": img.size[0], "height": img.size[1], "mode": img.mode, # 'RGB', 'L' (grayscale), 'RGBA' "filesize_kb": os.path.getsize(filepath) / 1024, "corrupted": False, } except Exception: return { "path": str(filepath), "width": None, "height": None, "mode": None, "filesize_kb": None, "corrupted": True, } THE フィールドは特に重要です。 3色チャンネル(赤、緑、青)を意味します。 グレイスケール(1チャンネル) 私たちのプレトレーニングモデルはRGBを期待しているので、後ですべてを変換する必要があります。 img.mode 'RGB' 'L' 'RGBA' 10万枚の画像をすべてスキャンしましょう。 all_files = labeled_files + unlabeled_files image_info = [get_image_info(f) for f in all_files] info_df = pd.DataFrame(image_info) そして結果を調べる: print(f"Total images scanned: {len(info_df)}") print(f"Corrupted images: {info_df['corrupted'].sum()}") print(f"\nResolution distribution:") print(info_df[~info_df["corrupted"]][["width", "height"]].describe().round(0)) print(f"\nColor modes: {info_df['mode'].value_counts().to_dict()}") print(f"File size (KB): min={info_df['filesize_kb'].min():.0f}, " f"max={info_df['filesize_kb'].max():.0f}, " f"mean={info_df['filesize_kb'].mean():.0f}") 出力で探すべきもの: 損傷した画像 > 0: すぐに削除します. 1 つの悪いファイルでさえ、あなたのトレーニングを崩壊させることができます。 異なる解像度: 幅や高さのための min ≠ max の場合、画像は異なるサイズを有します。 複数のカラーモード:「RGB」と「L」の両方を見ると、色とグレースケールの混合があります。 極端なファイルサイズ: 1KB ファイルは空または破損している可能性があります 50MB ファイルは圧縮されていない可能性があります - 調査する価値があります。 破損したファイルを削除する: corrupted_paths = set(info_df[info_df["corrupted"]]["path"].tolist()) if corrupted_paths: print(f"Removing {len(corrupted_paths)} corrupted images") labeled_files = [f for f in labeled_files if str(f) not in corrupted_paths] unlabeled_files = [f for f in unlabeled_files if str(f) not in corrupted_paths] 1.3 — クラス分布:私たちのラベルのセットはバランスを取っていますか? 産業的および医学的環境では、欠陥は稀です。あなたのラベルのセットは90%の「正常」と10%の「欠陥」である可能性があります。これは非常に重要です:常に「正常」を予測する怠惰なモデルは、完全に役に立たない一方で90%の正確さを得るでしょう。私たちは前もってバランスを認識する必要がありますので、後でそれを補償することができます。 class_counts = {} for class_dir in labeled_dir.iterdir(): if class_dir.is_dir(): count = len(list(class_dir.glob("*.png"))) class_counts[class_dir.name] = count print("Class distribution (labeled set):") for cls, count in class_counts.items(): pct = count / sum(class_counts.values()) * 100 print(f" {cls}: {count} images ({pct:.1f}%)") 不均衡が深刻な場合、私たちは後で呼ばれるテクニックを使用して処理します。 損失関数 - 基本的にモデルに「欠陥は偽アラームよりも4倍悪い」と言います。 pos_weight こちらも視覚化しましょう: fig, ax = plt.subplots(figsize=(6, 4)) ax.bar(class_counts.keys(), class_counts.values(), color=["#2ecc71", "#e74c3c"]) ax.set_title("Class Distribution (Labeled Images)") ax.set_ylabel("Number of images") plt.tight_layout() plt.savefig("outputs/class_distribution.png", dpi=150) plt.show() 1.4 — サンプル画像の視覚化:常にあなたモデルの前に見る これはパイプライン全体で最も重要なステップかもしれません。データを見てください. あなたは明らかに誤ってラベル化された画像、スキャンアーティファクト(黒い境界線、回転)、品質の問題(バラバラ、過剰曝露)、または欠陥が視覚的に微妙であり、あなたのタスクはあなたが思った以上に困難であることを発見するかもしれません。 fig, axes = plt.subplots(2, 5, figsize=(15, 6)) fig.suptitle("Sample Images — Top: Normal | Bottom: Defect", fontsize=14) for i, class_name in enumerate(["normal", "defect"]): class_files = list((labeled_dir / class_name).glob("*.png"))[:5] for j, filepath in enumerate(class_files): img = Image.open(filepath) axes[i, j].imshow(img, cmap="gray" if img.mode == "L" else None) axes[i, j].set_title(class_name, fontsize=10) axes[i, j].axis("off") plt.tight_layout() plt.savefig("outputs/sample_images.png", dpi=150) plt.show() この写真を観るにはちょっと時間をかけましょう(笑) 自分の目で欠点を見る? できない場合は、モデルも苦労します. 欠点が明らかである場合(深いカット、大きな裂け)それは励まし - モデルは違いを学ぶことができなければなりません. 彼らが微妙である場合(わずかな変色、髪のラインの裂け)は、特に良いプレプロセスと機能抽出が必要です。 あなた 続きを読む: 続きを読む: Pillow ドキュメント - 画像のロードに使用する Python イメージング ライブラリ NEU Surface Defect Database - 実世界のスチール表面欠陥データセットで実践できます。 Pillow ドキュメンタリー Surface Defectデータベース Part 2 — Preprocessing: speaking the model's language (モデルの言語を話す) なぜ私たちは単に原始画像をニューラルネットワークに供給できないのか ResNet50のようなプレトレーニングされたCNNは、非常に特定の種類の入力に訓練されました: 224×224ピクセル画像、RGB色で、ImageNetデータセットから計算された特定の平均値と標準偏差値で正常化されました。 ResNet50は「ImageNetを話す」のであれば、金属表面画像を理解するためには、まずImageNet形式に「翻訳」する必要があります。 RGB (3チャンネル) ヒストグラム均等化による対比性の向上 サイズ: 224×224 ピクセル値を正常化して ImageNet 統計に合致する 2.1 - ヒストグラム均等化とは何ですか、そしてなぜここで重要ですか? 通常の表面とカラフルな表面の違いは、ほんの数ピクセルの強度レベルでしか見えず、モデルが検出するのは非常に困難です。 ピクセルの強度を再分配し、全体の範囲(0 ~ 255)を均等に使用します。結果:微妙な機能が視覚的にも数的にも「ポップアップ」します。 Histogram equalization 私たちは、より高度なバージョンと呼ばれる (Contrast Limited Adaptive Histogram Equalization) グローバルエクバリゼーションとは異なり(画像全体に同じ変換を適用する)、CLAHE は画像を小さなテーブル(デフォルトで 8×8)に分割し、各テーブルを独立して均等化します。 CLAHE グローバルエクバリゼーションは、全体の画像のための単一の明るさスライダーを使用するようなものです - あなたは暗い角度を明るくすることができますが、すでに明るい中心を洗い流すことができます。 2.2 — Custom PyTorch Dataset を構築する PyTorch では、データロードを 2 つのクラスに分類します。 (一つの項目をロードする方法を知っている)および (アイテムをバッチやシャフルする方法を知っている) PyTorch は内蔵の データセットは、すべての画像にラベルがあると仮定します。当社のデータセットにはラベル付きとラベルなしの画像が含まれていますので、カスタムクラスが必要です。 Dataset DataLoader ImageFolder まずは、ステップごとに、スケールを構築しましょう。 import torch import torchvision.transforms as T from torch.utils.data import Dataset, DataLoader import cv2 class MetalSurfaceDataset(Dataset): """ Custom Dataset that handles both labeled and unlabeled images. Returns -1 as the label for unlabeled images. """ def __init__(self, image_paths, labels=None, transform=None): self.image_paths = image_paths self.labels = labels # None for unlabeled images self.transform = transform def __len__(self): return len(self.image_paths) PyTorch がどれだけの画像を持っているかを教えてくれます。 画像パスと(オプションとして)そのラベルを格納します。 __len__ __init__ 次に、コアメソッド、 , which loads and preprocesses a single image. We will break it into three stages. 単一の画像をロードし、プレプロセスします。 __getitem__ Stage 1 — Load the image and force RGB: def __getitem__(self, idx): # Load image and convert to RGB # .convert("RGB") handles grayscale → RGB conversion automatically # (it duplicates the single channel into R, G, and B) img = Image.open(self.image_paths[idx]).convert("RGB") なぜ ResNet50 は 3 つのチャンネルを期待しているので、画像がグレースケール(1 チャンネル)である場合、これはグレー値を R、G、B に複製します。 .convert("RGB") Stage 2 — Apply CLAHE histogram equalization: # Convert PIL Image → numpy array for OpenCV processing img_np = np.array(img) # Convert RGB → LAB color space # L = Lightness (brightness), A and B = color channels # We only equalize L (brightness) to avoid distorting colors img_lab = cv2.cvtColor(img_np, cv2.COLOR_RGB2LAB) # Create CLAHE object and apply to L channel # clipLimit=2.0 prevents over-amplification of noise # tileGridSize=(8,8) means 8x8 tiles for local equalization clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) img_lab[:, :, 0] = clahe.apply(img_lab[:, :, 0]) # Convert back LAB → RGB → PIL Image img_np = cv2.cvtColor(img_lab, cv2.COLOR_LAB2RGB) img = Image.fromarray(img_np) なぜLABではなく、RGBにCLAHEを直接適用するのか? なぜなら、R、G、Bチャンネルを独立して均等化すれば、ブルーをグリーンに変えることで、色を歪めるからです。 Stage 3 — Apply transforms and return: # Apply resize + normalize transforms if self.transform: img = self.transform(img) # Return the label (or -1 if this image has no label) label = self.labels[idx] if self.labels is not None else -1 return img, label 2.3 — トランスフォーメーションパイプライン:各ステップが行うこと 今、私たちは変換の順序を定義します. それぞれが特定の目的を持っています: preprocessing = T.Compose([ T.Resize((224, 224)), # (1) Resize to model's expected input size T.ToTensor(), # (2) PIL Image → PyTorch Tensor, scale [0,1] T.Normalize( mean=[0.485, 0.456, 0.406], # (3) Normalize with ImageNet statistics std=[0.229, 0.224, 0.225], ), ]) それぞれのステップを説明します: ResNet50 のアーキテクチャにはこのサイズが必要です。 300×400 画像を送信する場合、テンサーの形状は一致しませんし、PyTorch は崩壊します。 (1) Resize to 224×224. ピクセルフォーマットを HWC (Height × Width × Channels) から CHW (Channels × Height × Width) に変換し、PyTorch が期待するものであり、ピクセル値を [0, 255] 整数から [0.0, 1.0] 浮上値にスケールします。 (2) ToTensor. この魔法の数字は、 そして — 全体のImageNetデータセットのピクセル値の平均および標準偏差であり、チャンネルごとに計算される(R、G、B)。ResNet50はこれらの正確な正常化値で訓練されたので、内部の重量は、単位偏差で0を中心とした入力を「期待」します。 (3) Normalize with ImageNet mean and std. [0.485, 0.456, 0.406] [0.229, 0.224, 0.225] 2.4 - データセットとデータロードヤーの作成 すべてをまとめ、重要な原則: これらを混ぜると、私たちの評価が汚染されるでしょう。 labeled and unlabeled data must be kept strictly separate at all times. まず、ラベル化された画像パスとそのクラスラベルを収集します。 labeled_paths = [] labeled_labels = [] for class_idx, class_name in enumerate(["normal", "defect"]): class_dir = labeled_dir / class_name for fp in class_dir.glob("*.png"): labeled_paths.append(str(fp)) labeled_labels.append(class_idx) # 0 = normal, 1 = defect 次に、ラベルのない画像パスを収集します(ラベルは必要ありません): unlabeled_paths = [str(fp) for fp in unlabeled_files] PyTorch Dataset オブジェクトを作成する: labeled_dataset = MetalSurfaceDataset(labeled_paths, labeled_labels, preprocessing) unlabeled_dataset = MetalSurfaceDataset(unlabeled_paths, labels=None, transform=preprocessing) print(f"Labeled dataset: {len(labeled_dataset)} images") print(f"Unlabeled dataset: {len(unlabeled_dataset)} images") 最後に、DataLoaders にそれらを包装します。DataLoader は画像をバッチ(batch_size=32 は 32 枚の画像を同時に意味します)し、オプション的にそれらをシャフリングします。 labeled_loader = DataLoader(labeled_dataset, batch_size=32, shuffle=False) unlabeled_loader = DataLoader(unlabeled_dataset, batch_size=32, shuffle=False) なぜ なぜなら、我々は機能を抽出するつもりで、私たちのファイルリストと同じ順序で埋め込む必要があります。 shuffle=False 続きを読む: 続きを読む: torchvision transforms — full list CLAHE explained (OpenCV チュートリアル) PyTorch Dataset & DataLoader トレーニング torchvision transforms — full list CLAHE explained (OpenCV チュートリアル) PyTorch Dataset & DataLoader トレーニング Part 3 — 特性抽出: 画像を有意義な数字に変換する 3.1 — なぜ原始ピクセルは恐ろしい表現なのか 224×224 RGB イメージには 150,528 桁 (224 × 224 × 3 チャンネル) があります。それらのほとんどは騒音 - 照明、センサーのアーティファクト、圧縮アーティファクトの小さな変異です。さらに悪いのは、わずかに異なる角度や照明から取られた正確に同じグラフの 2 枚の写真が、完全に異なるピクセル値を持っています。もし私たちがラフ ピクセルをクラスターまたは分類しようとしたら、私たちにとって同じように見える画像はアルゴリズムにまったく異なります。 What we need is a representation that captures the 画像の「これはカットのように見える」、「これは滑らかな表面です」は、コンパクトで安定した数値形式です。 やる。 meaning embeddings 3.2 - プレトレーニングモデルとは何ですか、そしてなぜ私たちはゼロからトレーニングしないのです。 深いニューラルネットワークをゼロからトレーニングするには、多くのデータが必要です - 通常は数十万の画像です。我々は200のラベルされた画像を持っています。 ResNet50の2500万のパラメータを200の画像でトレーニングしようとすると、モデルはすべてのトレーニング画像を完璧に記憶しますが、新しい画像では完全に失敗します。 . overfitting 代わりに、我々はすでに ImageNet で訓練されていたモデルを使用します - 1000 カテゴリー(犬、猫、車、建物など)の1400万枚の画像のデータセットです。 彼らは、猫に適用されるのと同じように、鋼の表面に適用されます。 universal あなたの工場を点検するために経験豊富なカメラマンを雇うのと同じように考えてください. 彼らはこれまで鉄板を見たことがありませんが、彼らはすでにそれを知っています。 : 彼らは異常なテクスチャ、表面品質の突然の変化、規範を破るパターンを見つけることができます。 見る 3.3 - ロード ResNet50 プレトレーニングされたモデルをロードしましょう: import torchvision.models as models resnet = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1) このシングルラインでは(初めて) ResNet50 モデルをダウンロードし、その重量は ImageNet でトレーニングされています。 3.4 - パラメータの凍結 我々は、学んだ機能のいずれかを変更したくない。我々は、読書のみのツールとしてResNetを使用しています。 for param in resnet.parameters(): param.requires_grad = False PyTorch は「これらのパラメータのためのグレディエントを計算しないでください」と述べています。これには2つの利点があります:それは事前トレーニングの重量の偶然の修正を防ぎ、推論をより速くします(グレディエント追跡なし = より少ない計算とメモリ)。 requires_grad = False 3.5 - 分類ヘッダーの削除 ResNet50のアーキテクチャは以下のようになります。 Input image (224×224×3) ↓ [Convolutional layers] — learn visual features ↓ [Average Pooling] — compress spatial dimensions → 2048-dim vector ↓ [Fully Connected layer] — classify into 1000 ImageNet categories ↓ Output (1000 probabilities) We don't want the last Fully Connected layer, because it's specific to ImageNet's 1000 classes (dog, cat, airplane...) and useless for our task. 我々は、ImmageNetの1000クラス(犬、猫、飛行機など)に特有のVectorを2048次元で求めています。 feature_extractor = torch.nn.Sequential(*list(resnet.children())[:-1]) このラインは何をしているのか: ResNet のすべてのレイヤーをリストとして返します。 最後の1つを除くすべてのレイヤー(FCレイヤー)を取ります。 それらをモデルに戻す。 resnet.children() [:-1] Sequential(*) feature_extractor.eval() モードは dropout を無効にし、バッチ正常化のための実行統計を使用します。 出力 - 同じ画像は常に同じ埋め込みを生成します。 eval() deterministic device = torch.device("cuda" if torch.cuda.is_available() else "cpu") feature_extractor = feature_extractor.to(device) print(f"Feature extractor ready on {device}") 利用可能な場合のGPUを使用すると、機能抽出が約10倍速くなります。 3.6 - 抽出機能 次に、機能抽出器を介して画像のバッチを供給し、埋め込みを収集する機能を書こう。 外部構造: def extract_embeddings(dataloader, model, device): """ Feed all images through the model and collect embeddings. Returns: embeddings: numpy array, shape (n_images, 2048) labels: numpy array, shape (n_images,) — -1 if unlabeled """ all_embeddings = [] all_labels = [] 結果をリストに蓄積するのは、画像をバッチ(32枚ずつ)で処理するためで、一度にすべてではありません(GPUメモリには適していません)。 主なループ: with torch.no_grad(): for batch_images, batch_labels in dataloader: batch_images = batch_images.to(device) features = model(batch_images) gradient tracking を無効にします - 重要なのは、我々は推論だけをやっているので、トレーニングではありません。これだけでメモリの使用量を半分に削減し、物事を加速します。 画像をGPUに移動する場合 torch.no_grad() batch_images.to(device) The output of 形がある — 最後の2つの次元は、平均集合の空間的残骸です. We need to squeeze them: model(batch_images) (batch_size, 2048, 1, 1) features = features.squeeze(-1).squeeze(-1) # Now shape is (batch_size, 2048) — that's our embedding 最後に、結果をCPUに戻す(numpy は GPU テンサーで動作しません)と保存します。 all_embeddings.append(features.cpu().numpy()) all_labels.append(batch_labels.numpy()) return np.concatenate(all_embeddings), np.concatenate(all_labels) すべてのバッテリーを一つのマレージに組み合わせる。 np.concatenate 3.7 - 抽出の実行 print("Extracting embeddings for labeled images...") labeled_embeddings, labeled_labels_arr = extract_embeddings( labeled_loader, feature_extractor, device ) print(f" Shape: {labeled_embeddings.shape}") # Expected: (200, 2048) 200 枚の画像、それぞれ 2048 次元のベクターで表されます. That is a 73x compression compared to raw pixels (150,528 → 2,048) — and the compressed representation is より有意義 遠く print("Extracting embeddings for unlabeled images...") unlabeled_embeddings, _ = extract_embeddings( unlabeled_loader, feature_extractor, device ) print(f" Shape: {unlabeled_embeddings.shape}") # Expected: (9800, 2048) We discard the labels (they're all -1 anyway) with the 変数 _ 3.8 - 埋め込みを節約する(毎回再抽出しないでください!) 機能抽出は最も高価なステップであり、10,000 枚の画像の GPU で 30 分を超える可能性があります。 np.save("data/labeled_embeddings.npy", labeled_embeddings) np.save("data/labeled_labels.npy", labeled_labels_arr) np.save("data/unlabeled_embeddings.npy", unlabeled_embeddings) print("Embeddings saved to disk") 後で、即座にリロードできます。 . np.load("data/labeled_embeddings.npy") 3.9 衛生検査:埋め込みは合理的ですか? 前へ進む前に、迅速な検証をします. ゴミ入り、ゴミ出し - 私たちの埋め込みが健全であることを確認しましょう: print(f"Embedding statistics:") print(f" Mean: {labeled_embeddings.mean():.4f}") print(f" Std: {labeled_embeddings.std():.4f}") print(f" Min: {labeled_embeddings.min():.4f}") print(f" Max: {labeled_embeddings.max():.4f}") print(f" NaN: {np.isnan(labeled_embeddings).any()}") print(f" Inf: {np.isinf(labeled_embeddings).any()}") 何を期待するか: 約 0.3-0.5, std 約 0.5-1.0, NaN なし, Inf なし. NaN 値を見ると、破損した画像は、おそらく掃除のステップを通過しました。 さらに進む: さらに進む: Transfer Learning Explained (PyTorch チュートリアル) ResNet paper (He et al., 2015) — the original architecture that introduced skip connections. ジャンプ接続を導入したオリジナルアーキテクチャ Feature extraction vs fine-tuning — スタンフォードCS231nコースノート Transfer Learning Explained (PyTorch チュートリアル) シンガポール(He et al., 2015) 「Fine-Tuning vs Extract」 第4部 監督されていない集団化:暗闇における構造の発見 4.1 — クラスターリングは何をし、なぜ私たちがそれを必要とするのか 我々は今、それぞれの画像のコンパクトな数値概要を含む1万個の埋め込みを持っています。そのうちの200個はラベルを持っています。その他の9800個はそうではありません。 「正常」と「欠陥」に匹敵するデータに groupings ベースの仮定:当社の埋め込みが良い場合(ResNet50埋め込みが通常の場合)、同じタイプの画像が表示されます。 通常の表面は集合し、欠陥の表面は集合し、ラベルがなくても構造は存在する - 集合はそれを明らかにします。 近く しかし、まず、実用的な問題: 2048 次元を視覚化することは不可能で、いくつかのアルゴリズムを遅らせる必要があります。 4.2 - 組み込みを標準化する クラスタリングアルゴリズム、特にK-Meansは、点間の距離を計算します. 一つの次元が0から1000、もう一つの次元が0から0.01の場合、最初の次元は完全に距離を支配します - まるで2番目の次元が存在しないかのように。 from sklearn.preprocessing import StandardScaler # Combine labeled + unlabeled for joint standardization all_embeddings = np.concatenate([labeled_embeddings, unlabeled_embeddings], axis=0) scaler = StandardScaler() all_embeddings_scaled = scaler.fit_transform(all_embeddings) なぜラベルと非ラベルを共に標準化するのか? なぜなら、それらは同じ分布(同じ工場、同じカメラ)から来ているからです。 # Split back — we'll need them separate later labeled_scaled = all_embeddings_scaled[:len(labeled_embeddings)] unlabeled_scaled = all_embeddings_scaled[len(labeled_embeddings):] print(f"After standardization: mean={all_embeddings_scaled.mean():.4f}, " f"std={all_embeddings_scaled.std():.4f}") 4.3 — PCA でサイズを減らす PCA(Principal Component Analysis)は、データにおける最大変数の方向を発見し、上位の方向にプロジェクトします。 from sklearn.decomposition import PCA pca = PCA(n_components=50, random_state=42) all_pca = pca.fit_transform(all_embeddings_scaled) print(f"PCA: 2048 → 50 dimensions") print(f"Variance retained: {pca.explained_variance_ratio_.sum():.1%}") ~95%の変数を保持すると、情報の5%だけを捨てたが、次元性が40倍減少したことを意味します。 また、各コンポーネントがどれだけ貢献するかを視覚化しましょう: plt.figure(figsize=(10, 4)) plt.plot(np.cumsum(pca.explained_variance_ratio_), marker="o", markersize=3) plt.xlabel("Number of PCA components") plt.ylabel("Cumulative explained variance") plt.title("PCA: How many components do we need?") plt.axhline(y=0.95, color="r", linestyle="--", label="95% threshold") plt.legend() plt.grid(True, alpha=0.3) plt.tight_layout() plt.savefig("outputs/pca_variance.png", dpi=150) plt.show() この「肘のプロット」は、より多くのコンポーネントを追加することにより、大幅な利益を生み出すのを止める場所を示しています。 4.4 — T-SNE で視覚化 t-SNE は、視覚化のために特別に設計された非線形次元減少技術です。 :高次元空間に近い画像は2Dのスロットに近いものになります. これにより、正常な画像と欠陥画像が自然に分離しているかどうかをチェックするのに最適です。 local structure 重要な警告: t-SNE はグローバル距離を歪曲する - クラスター間のスペースは意味がありません. Use it only for visualization, and cluster on the original (or PCA-reduced) embeddings. never cluster on t-SNE output. from sklearn.manifold import TSNE # Apply t-SNE on PCA output (faster and more stable than on raw 2048-dim) tsne = TSNE(n_components=2, random_state=42, perplexity=30, n_iter=1000) all_tsne = tsne.fit_transform(all_pca) The parameter roughly controls the "neighborhood size" — how many nearby points t-SNE considers. 30は、私たちのサイズのデータセットのための合理的なデフォルトです。 perplexity 次に、t-SNEの座標を分けましょう。 labeled_tsne = all_tsne[:len(labeled_embeddings)] unlabeled_tsne = all_tsne[len(labeled_embeddings):] 視覚化する: fig, axes = plt.subplots(1, 2, figsize=(14, 6)) # Left plot: labeled images only, colored by true label for cls_idx, cls_name, color in [(0, "Normal", "#2ecc71"), (1, "Defect", "#e74c3c")]: mask = labeled_labels_arr == cls_idx axes[0].scatter(labeled_tsne[mask, 0], labeled_tsne[mask, 1], label=cls_name, alpha=0.7, s=40, c=color) axes[0].set_title("t-SNE: Labeled Images (True Labels)") axes[0].legend() 片側に緑色、片側に赤色の2つの異なる雲が見える場合、それは素晴らしい兆候です ResNet50の埋め込みは、正常な表面と欠陥の表面の違いを真に捉えることを意味します。 # Right plot: all images (unlabeled in gray, labeled overlaid) axes[1].scatter(unlabeled_tsne[:, 0], unlabeled_tsne[:, 1], c="lightgray", alpha=0.2, s=10, label="Unlabeled") for cls_idx, cls_name, color in [(0, "Normal", "#2ecc71"), (1, "Defect", "#e74c3c")]: mask = labeled_labels_arr == cls_idx axes[1].scatter(labeled_tsne[mask, 0], labeled_tsne[mask, 1], label=f"Labeled: {cls_name}", alpha=0.8, s=40, c=color) axes[1].set_title("t-SNE: All Images (Labeled in Color)") axes[1].legend() plt.tight_layout() plt.savefig("outputs/tsne_visualization.png", dpi=150) plt.show() 右のスロットでは、グレー・クラウド(ラベルされていない画像)は、カラーの点と重なり合うべきである。これは、ラベルされていない画像とラベルされていない画像が同じ配布から来ていることを確認する - 半監督学習が機能するための必要条件です。 4.5 K-Means クラスタリング K-Means は最も単純で最も広く使用されているクラスタリングアルゴリズムで、データを正確に k グループに分割し、次第に各ポイントを最寄りのクラスタセンターに割り当て、その後センターを更新します。 私たちは2つのクラス(正常と欠陥)があることを知っているので、k=2で始めるが、データがより多くの構造があるかどうか(例えば、異なる構造があるかどうか)を確認するためにk=3、4、5もテストする。 個々のクラスターを形成する欠陥)。 タイプ クラスターが実際のラベルとどのように一致するかを評価するには、我々は ARI = 1.0 は真のラベルと完全に一致することを意味します ARI = 0.0 はランダムなクラスタリングを意味します ARI < 0 はランダムより悪いことを意味します。 ARI (Adjusted Rand Index) from sklearn.cluster import KMeans from sklearn.metrics import adjusted_rand_score, silhouette_score print("K-Means Clustering:") print(f" {'k':<5s} {'ARI':>8s} {'Silhouette':>12s}") print(f" {'-'*27}") for k in [2, 3, 4, 5]: kmeans = KMeans(n_clusters=k, random_state=42, n_init=10) all_clusters = kmeans.fit_predict(all_embeddings_scaled) # ARI: compare clusters vs true labels (on labeled images only) labeled_clusters = all_clusters[:len(labeled_embeddings)] ari = adjusted_rand_score(labeled_labels_arr, labeled_clusters) # Silhouette: internal quality measure (no labels needed) # How well-separated are the clusters? Range: -1 to +1 sil = silhouette_score(all_embeddings_scaled, all_clusters) print(f" {k:<5d} {ari:>8.4f} {sil:>12.4f}") THE パラメータは、K-Means が異なるランダムな初期化で 10 回実行され、最良の結果を保持することを意味します。 n_init=10 k=2 が最も高い ARI を示す場合、それはデータが正常と欠陥と一致する 2 つの自然なグループを持っていることを確認します。 4.6 — DBSCANクラスタリング(代替アプローチ) DBSCAN は K-Means とは大きく異なります。クラスターの数を指定する代わりに、2 つのパラメータを指定します。 eps (epsilon) : 2 つの点の間の最大距離は隣人とみなされるためです. それを「どのくらい近くで十分に近いか」と考えてください。 min_samples: 密度の高い領域(クラスター)を形成するために必要な最小の点数です。それを「クラスターとして数えるためには、近所がどれだけ混雑しているか」として考えてください。 DBSCAN はクラスターの数を自動的に決定し、アウトライヤー (クラスターに属していないポイント - -1 としてラベル化) を識別します。 from sklearn.cluster import DBSCAN print("\nDBSCAN Clustering:") print(f" {'eps':<6s} {'min_s':<7s} {'clusters':>9s} {'noise':>7s} {'ARI':>8s}") print(f" {'-'*40}") 「正しい」値はデータに依存するので、複数のパラメータ組み合わせをテストする必要があります。 for eps in [3.0, 5.0, 7.0, 10.0]: for min_samples in [5, 10, 20]: dbscan = DBSCAN(eps=eps, min_samples=min_samples) db_clusters = dbscan.fit_predict(all_pca) # Use PCA-reduced data n_clusters = len(set(db_clusters)) - (1 if -1 in db_clusters else 0) n_noise = (db_clusters == -1).sum() if n_clusters >= 2: labeled_db = db_clusters[:len(labeled_embeddings)] mask = labeled_db != -1 # Exclude noise points from ARI if mask.sum() > 10: ari = adjusted_rand_score(labeled_labels_arr[mask], labeled_db[mask]) print(f" {eps:<6.1f} {min_samples:<7d} {n_clusters:>9d} " f"{n_noise:>7d} {ari:>8.4f}") PCA削減データを使用することに注意してください( DBSCANは、すべての距離が類似する(「次元性の呪い」)ので、非常に高い次元で戦います。 all_pca DBSCAN の最高の ARI を K-Means の最高のものと比較し、優勝者を選択してください。 4.7 — T-SNE プラットフォーム上のクラスターの視覚化 私たちのt-SNEビジュアル化で最も優れたクラスタリングがどのように見えるかを見てみましょう: best_kmeans = KMeans(n_clusters=2, random_state=42, n_init=10) all_cluster_ids = best_kmeans.fit_predict(all_embeddings_scaled) fig, ax = plt.subplots(figsize=(8, 6)) scatter = ax.scatter(all_tsne[:, 0], all_tsne[:, 1], c=all_cluster_ids, cmap="coolwarm", alpha=0.4, s=15) ax.set_title("K-Means Clusters (k=2) on t-SNE") plt.colorbar(scatter, label="Cluster ID") plt.tight_layout() plt.savefig("outputs/kmeans_clusters_tsne.png", dpi=150) plt.show() このプロットの 2 つの色が、タグ付けされた t-SNE プロットで見た 2 つのグループとほぼ一致する場合、クラスタリングは動作します。 4.8 — ラベルのない画像に偽のラベルを割り当てる 次に、重要なステップ:クラスターの割り当てをとって、未ラベルな画像の「弱いラベル」として扱います。しかし、K-Means はクラスター ID を任意に割り当てます。 pseudo-label を削除する: unlabeled_pseudo_labels = all_cluster_ids[len(labeled_embeddings):] リアルラベルとの調和をチェックする: labeled_cluster_ids = all_cluster_ids[:len(labeled_embeddings)] # What fraction of labeled images in cluster 0 are actually "normal"? cluster_0_normal_rate = (labeled_labels_arr[labeled_cluster_ids == 0] == 0).mean() cluster_1_normal_rate = (labeled_labels_arr[labeled_cluster_ids == 1] == 0).mean() print(f"Cluster 0: {cluster_0_normal_rate:.1%} of labeled images are 'normal'") print(f"Cluster 1: {cluster_1_normal_rate:.1%} of labeled images are 'normal'") クラスター 0 がほとんどの欠陥である場合 (normal_rate < 50%) は、マッピングを変換します。 if cluster_0_normal_rate < 0.5: unlabeled_pseudo_labels = 1 - unlabeled_pseudo_labels print("Cluster IDs flipped to match convention (0=normal, 1=defect)") 配布を見てみよう: print(f"\nPseudo-label distribution:") print(f" Normal (0): {(unlabeled_pseudo_labels == 0).sum()} images") print(f" Defect (1): {(unlabeled_pseudo_labels == 1).sum()} images") 現在、非常に異なる特徴を持つ2つの別々のデータセットがあります。 強くラベル付けられた - 実質的な専門家ラベルを持つ200の画像. 高品質、小量. これは私たちの基本的な真実です。 ラベルが弱い — 9800 画像 クラスターベースの偽のラベル 低品質(いくつかのラベルが間違っている)、しかし大量。 黄金のルール: They serve different purposes in the next step. never mix these two. 続きを読む: 続きを読む: K-Means explained (scikit-learn) を参照 DBSCAN explained (scikit-learn) t-SNE を正しく読み取る方法 (Distill) — essential reading K-Means explained (scikit-learn) を参照 DBSCAN explained (scikit-learn) t-SNE を正しく読み取る方法(Distill) 第5部 半監督トレーニング:実際の実験 5.1 — 私たちの二段階アプローチの背後にある論理 工場で新しい品質検査官を訓練していると想像してください。 : 9800枚の写真を見せて、「I これらは正常であり、これらは欠陥であるが、私は100%確信していない」 検査官は粗末な精神モデルを形成し始める。 いくつかのラベルは間違っているが、全体的なパターン - 正常な表面は滑らかで均一であり、欠陥した表面には不規則性がある - ほとんどが正しい。 . Phase 1 (pre-training on pseudo-labels) 考える 直感 次に、専門家によって慎重に検証された200枚の写真を彼らに示すと、「これらは明らかに正常で、これらは明らかに欠陥である」 監査官は、彼らの精神モデルを改良し、第1段階の誤りを訂正し、エッジケースで彼らの判断を鋭くする。 Phase 2 (fine-tuning on real labels) 結果:ある検察官が 10,000 images (building broad intuition) and has been 200の専門家による検証実例(精度を確保する)により、この検査官は、200の検証実例しか見たことがない検査官を上回ることを期待します。 見る カリブレーション これを証明するために、我々は2つの並行実験を実行する: 実験A - 監督のみ: 200 枚のラベル付き画像のみをトレーニング 実験B - 半監視:9800の偽のラベル付き画像のプレトレイン、その後200のラベル付き画像のフィンタウン 同じモデルアーキテクチャ、同じテストセット. 唯一の違いは、モデルがラベルされていないデータを見るかどうかです。 5.2 — 分類の構築:建築 ResNet50を再びバックボードとして使用しますが、今回は The final layer with a binary classifier and we それは(第3部とは異なり、我々は単に特性を抽出した)。 replace train import torch.nn as nn class DefectClassifier(nn.Module): """ Binary classifier: Normal (0) vs Defect (1). Based on ResNet50 with a custom classification head. """ def __init__(self, dropout_rate=0.5): super().__init__() self.backbone = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1) num_features = self.backbone.fc.in_features # 2048 ここでは、オリジナルのImageNet分類ヘッダー(2048 → 1000クラス)を私たちのものに置き換えます。 self.backbone.fc = nn.Sequential( nn.Dropout(p=dropout_rate), # Anti-overfitting nn.Linear(num_features, 1), # Binary output ) def forward(self, x): return self.backbone(x) なぜ たった200個のラベル付き画像と2500万個のパラメータで、過剰装備が主な脅威です Dropout は、トレーニングの各ステップの間に神経の50%をランダムに無効にし、ネットワークが過剰装置を学ぶように強制します。 Dropout(0.5) なぜ instead of Linear(2048, 2)? For binary classification, a single output neuron with a sigmoid activation is mathematically equivalent to two neurons with softmax, but simpler and slightly more numerically stable. Linear(2048, 1) 5.3 損失機能:クラス不均衡の処理 トレーニングループを書く前に、損失関数を論じましょう。 (Binary Cross-Entropy with Logits), which combines sigmoid activation with binary cross-entropy in a single, numerically stable operation. (Binary Cross-Entropy with Logits), which combines sigmoid activation with binary cross-entropy in a single, numerically stable operation. BCEWithLogitsLoss 重要な追加は、 : pos_weight pos_weight = torch.tensor([4.0]).to(device) criterion = nn.BCEWithLogitsLoss(pos_weight=pos_weight) 何が 損失関数は、「欠陥(偽ネガティブ)は罰せられるべきである。 これはクラスの不均衡を補償するものであり、それなしでは、モデルは常に「正常」を予測することによって80%の精度を達成することができる。 pos_weight=4.0 4 times more 値 4.0 はクラス比率に基づく粗略な推定です. あなたが 80% 正常 / 20% 欠陥を持っている場合は、 この値を調節できますが、 4.0 は良い出発点です。 pos_weight = 80/20 = 4.0 5.4 — The optimizer: AdamW with weight decay import torch.optim as optim optimizer = optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-4) なぜAdamW? それは適切な体重崩壊(L2規律化)のアダムです。 軽く大きな重量を罰し、これは過剰装備に対する保護のもう一つの層です。それをモデルに「より単純な説明を好む」と言って考えてください。 weight_decay=1e-4 5.5 - 学習率スケジュール scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, patience=3, factor=0.5) これにより、検証損失が改善しなくなったときに学習率が自動的に低下します。 「減らす前に改善しない3つの時代を待つ」という意味です。 「学習率を0.5倍にする」という意味です。これは、モデルが最小限に近づくにつれて、過剰化を防ぐために重要です。 patience=3 factor=0.5 5.6 — The training loop: one epoch at a time 今、完全なトレーニング機能を構築しましょう. ループの各部分を別々に通過します。 (one epoch - one pass through all training data) The training phase from sklearn.metrics import f1_score def train_model(model, train_loader, val_loader, epochs, lr, device, phase_name=""): """Train the model and track validation F1 score.""" pos_weight = torch.tensor([4.0]).to(device) criterion = nn.BCEWithLogitsLoss(pos_weight=pos_weight) optimizer = optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-4) scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, patience=3, factor=0.5) best_f1 = 0 for epoch in range(epochs): # ---- TRAINING ---- model.train() # Enable dropout, update batch norm stats for images, labels in train_loader: images = images.to(device) labels = labels.float().unsqueeze(1).to(device) # .float() because BCEWithLogitsLoss expects float targets # .unsqueeze(1) adds a dimension: shape (batch,) → (batch, 1) optimizer.zero_grad() # Reset gradients from previous batch outputs = model(images) # Forward pass loss = criterion(outputs, labels) # Compute loss loss.backward() # Compute gradients (backpropagation) optimizer.step() # Update weights Each batch goes through the classic cycle: forward pass → compute loss → backpropagate gradients → update weights → reset gradients for next batch. それぞれのバッチは、クラシックなサイクルを通過します。 (各トレーニング期間中): The validation phase # ---- VALIDATION ---- model.eval() # Disable dropout, use fixed batch norm stats all_preds, all_true = [], [] val_loss_total = 0 with torch.no_grad(): # No gradients needed for evaluation for images, labels in val_loader: images = images.to(device) outputs = model(images) # Compute validation loss val_loss_total += criterion( outputs, labels.float().unsqueeze(1).to(device) ).item() # Convert raw logits → binary predictions # sigmoid maps logits to [0, 1], then threshold at 0.5 preds = (torch.sigmoid(outputs) >= 0.5).int().cpu().numpy().flatten() all_preds.extend(preds) all_true.extend(labels.numpy()) 注目 The これは重要なことです:それは dropout (すべてのニューロンがアクティブ) を無効にし、バッチ統計の代わりにバッチ正常化のための実行統計を使用します。 model.eval() Track metrics and update scheduler: val_f1 = f1_score(all_true, all_preds, average="binary") scheduler.step(val_loss_total) # Reduce LR if loss plateaued if val_f1 > best_f1: best_f1 = val_f1 if (epoch + 1) % 5 == 0: print(f" [{phase_name}] Epoch {epoch+1}/{epochs}: val_f1={val_f1:.4f}") print(f" [{phase_name}] Best F1: {best_f1:.4f}") return best_f1 We track the モデルはしばしばトレーニング終了前にピークします(その後、彼らはわずかに上回る可能性があります)。 best 5.7 - データ分割の準備 ラベル化されたデータを列車(70%)とテスト(30%)に分けます。 : どちらの実験でもトレーニングに使用されることはありません. Stratification ensures the class ratio is preserved in both splits. sacred from sklearn.model_selection import train_test_split labeled_train_idx, labeled_test_idx = train_test_split( range(len(labeled_paths)), test_size=0.3, random_state=42, stratify=labeled_labels, ) 必要な3つのデータセットを作成する: # 1. Labeled training data (for supervised training + fine-tuning) train_labeled_ds = MetalSurfaceDataset( [labeled_paths[i] for i in labeled_train_idx], [labeled_labels[i] for i in labeled_train_idx], preprocessing, ) # 2. Test data (for evaluation only — NEVER used for training) test_ds = MetalSurfaceDataset( [labeled_paths[i] for i in labeled_test_idx], [labeled_labels[i] for i in labeled_test_idx], preprocessing, ) # 3. Weakly labeled data (pseudo-labels from clustering) weakly_labeled_ds = MetalSurfaceDataset( unlabeled_paths, unlabeled_pseudo_labels.tolist(), preprocessing, ) データロッカーたち: train_labeled_loader = DataLoader(train_labeled_ds, batch_size=16, shuffle=True) test_loader = DataLoader(test_ds, batch_size=16, shuffle=False) weakly_labeled_loader = DataLoader(weakly_labeled_ds, batch_size=32, shuffle=True) print(f"Train (labeled): {len(train_labeled_ds)} images") print(f"Test: {len(test_ds)} images") print(f"Weakly labeled: {len(weakly_labeled_ds)} images") 異なるバッチサイズに注意:小さいラベル付きセット(シーズンごとに画像が少なくなる)、大きい弱いラベル付きセット(処理が速くなる)のための16。 モデルがサンプルの順序を学ぶのを防ぐためのトレーニング。 shuffle=True 5.8 — 実験A:監督のみ(ベースライン) これはより単純な実験です. We train a fresh model using ONLY the 140 labeled training images (the other 60 are reserved for testing). これは、半監督学習なしで得るパフォーマンスです。 print("=" * 60) print("EXPERIMENT A: SUPERVISED ONLY (140 labeled images)") print("=" * 60) model_supervised = DefectClassifier(dropout_rate=0.5).to(device) f1_supervised = train_model( model_supervised, train_labeled_loader, test_loader, epochs=30, lr=1e-4, device=device, phase_name="Supervised" ) 5.9 — 実験B:半監督(二段階アプローチ) 現在、完全なパイプラインです。第1段階は、9800の偽のラベル付き画像からモデルに幅広い直感を与えます。第2段階は、140の実際のラベルでそれを鋭くします。 Phase 1 — Pre-training on weakly labeled data: print("\n" + "=" * 60) print("EXPERIMENT B: SEMI-SUPERVISED") print("=" * 60) model_semi = DefectClassifier(dropout_rate=0.5).to(device) print("\nPhase 1: Pre-train on pseudo-labeled data (9,800 images)...") train_model( model_semi, weakly_labeled_loader, test_loader, epochs=10, lr=1e-4, device=device, phase_name="Pre-train" ) 私たちはここで10の時代にしかトレーニングをしません、なぜなら、偽のラベルは騒がしいからです。 Phase 2 — Fine-tuning on strongly labeled data: print("\nPhase 2: Fine-tune on real labeled data (140 images)...") f1_semi = train_model( model_semi, train_labeled_loader, test_loader, epochs=20, lr=5e-5, device=device, phase_name="Fine-tune" ) 注目 The (5e-5 vs 1e-4 in phase 1). This is deliberate and critical. If we use a high learning rate during fine-tuning, the model would quickly "forgive" everything it learned during pre-training — the gradients would be too large and would overwrite the pre-trained weights. A gentle learning rate allows the model to make small corrections to its existing knowledge, keeping the broad patterns from phase 1 while fixing the errors with real labels.(5e-5 vs 1e-4 in phase 1)。 lower learning rate これは工場検察官に似ている:あなたは彼らのトレーニングを段階2でゼロから始めることはありません。 5.10 — 最終評価:真実の瞬間 現在、両モデルを複数のメトリクスで同じテストセットで評価しています。 まず、評価機能: from sklearn.metrics import roc_auc_score, classification_report def full_evaluation(model, test_loader, device, name): """ Evaluate on the test set. Returns F1 score and AUC-ROC. """ model.eval() all_preds, all_probs, all_true = [], [], [] with torch.no_grad(): for images, labels in test_loader: outputs = model(images.to(device)) probs = torch.sigmoid(outputs).cpu().numpy().flatten() all_probs.extend(probs) all_preds.extend((probs >= 0.5).astype(int)) all_true.extend(labels.numpy()) 両方収集 (ランキング品質を測るAUC-ROC)および (F1の場合、分類品質を0.5の値段で測定する): probabilities binary predictions f1 = f1_score(all_true, all_preds, average="binary") auc = roc_auc_score(all_true, all_probs) print(f"\n{name}:") print(f" F1 Score: {f1:.4f}") print(f" AUC-ROC: {auc:.4f}") print(classification_report( all_true, all_preds, target_names=["Normal", "Defect"] )) return f1, auc なぜ なぜなら、不均衡なクラスでは、正確さは誤りであるからである。常に「正常」を予測するモデルは、80%の正確さを得るが、0%の欠陥を思い出させるからである。 F1 なぜ モデルがどれほど優れているかを測ります。 画像(誤った画像は通常のものより高い確率を得るべきである)は、分類の限界に関係なく、AUC は 1.0 を意味し、完璧なランキングを意味し、 0.5 はランダムを意味します。 AUC-ROC ランク 次に、比較: f1_sup, auc_sup = full_evaluation( model_supervised, test_loader, device, "SUPERVISED ONLY" ) f1_semi, auc_semi = full_evaluation( model_semi, test_loader, device, "SEMI-SUPERVISED" ) 最後の判決: print("=" * 60) print("FINAL COMPARISON") print("=" * 60) print(f" {'Metric':<12s} {'Supervised':>12s} {'Semi-supervised':>16s} {'Delta':>8s}") print(f" {'-'*50}") print(f" {'F1':<12s} {f1_sup:>12.4f} {f1_semi:>16.4f} {f1_semi - f1_sup:>+8.4f}") print(f" {'AUC-ROC':<12s} {auc_sup:>12.4f} {auc_semi:>16.4f} {auc_semi - auc_sup:>+8.4f}") もしデルタ列がポジティブな数値を示しているならば、非ラベル化されたデータが有用であることを証明しました。偽のラベルは、不完全であるにもかかわらず、モデルに200画像の純粋な監視が一致できないような頭のスタートを与えました。 5.11 - 結果の解釈 以下は、比較の読み方です。 F1 +0.05 以上改善:半監督のための明確な勝利. ラベルなしのデータは有意義なシグナルを提供しました。 F1 は +0.01 から +0.04 に改善されました: 適度な改善です. 半監督は役に立ちますが、マージンは小さい。 F1 変わらず、あるいは悪くなかった: 偽のラベルがあまりにも騒がしく、またはクラスターが真の構造をキャプチャしなかった. Try different feature extractors, different cluster algorithms, or higher confidence thresholds for pseudo-labels. 続きを読む: 続きを読む: Pseudo-labelling paper (Lee, 2013) — the original approach 半監督学習調査(van Engelen & Hoos) - 方法の包括的な概要 PyTorch トレーニング ループ ベスト・プラクティス Pseudo-labelling paper (Lee, 2013) 半監督学習調査(van Engelen & Hoos) PyTorch トレーニング ループ ベスト・プラクティス Part 6 — 数百万の画像へのスケーリング:現実的なロードマップ ビジネスからの質問 「あなたのコンセプトの証明書は1万枚の画像で動作します。私たちは4百万枚の画像を処理しています。このパイプラインを5000ユーロの予算でスケールできますか?」 これは、あなたが実際のプロジェクトで直面する質問です。それを正直に分解しましょう。 コンピュータ費用 ボトルネックです. 1 つの GPU を使用した 10,000 枚の画像では、約 30 分かかります. 線形スケーリング: Feature extraction 4,000,000 images ÷ 10,000 images × 30 min = 12,000 min = 200 GPU-hours クラウド GPU インスタンス (T4 または A10 GPU を搭載した Azure NC シリーズ) の場合、約 2 時間 . €400 標準 K-Means は、すべてのデータをメモリにロードして距離を計算します。 2048 次元の 4M インベーダー(各 4 バイトフロート): Clustering 4,000,000 × 2,048 × 4 bytes = ~32 GB just for the embeddings ほとんどのマシンでRAMに合わないソリューション:使用 scikit-learn から、一度に 10,000 個のサンプルを処理するデータを処理します. Same result, fraction of memory. MiniBatchKMeans 4M pseudo-labeled imagesのプレトレーニングには、約50時間のGPU時間がかかります。 . CNN training €100 貯蔵コスト レイアウト画像:4M × ~50 KB average = 入力: 4M × 2048 × 4 バイト = Azure Blob Storage では ~€0.02/GB/month で、 . 200 GB 32 GB €5/month ラベル戦略 もし200のラベルが規模では不十分なら、より多くのラベルを表示できるだろう。 画像あたり1ユーロ(品質管理を含む)で、2000のラベルを追加する。 しかし、より賢いアプローチがある: . €2,000 active learning アクティブな学習はモデルを選択することを可能にする ランダムに2000枚の画像を選択する代わりに、モデルは最も不確実なもの、つまり最も学ぶべき画像を特定します。 どの アクティブな学習で、2000の代わりに500の追加ラベルしか必要ないかもしれません。 . €500 総予算予算 Feature extraction (GPU): €400 CNN training (GPU): €100 Storage (year 1): €60 Additional labeling: €500 – €2,000 ────────────────────────────────────── TOTAL: €1,060 – €2,560 5000ユーロの予算の範囲内で、実験や再稼働のためのスペースが残されています。 成功の5つの条件 ローカルハードウェアではなく、クラウドGPUを使用してください. Rental by the hour, pay only for what you use. 通常の KMeans の代わりに MiniBatchKMeans を使用します. 同じ品質、メモリの 100 倍減少。 バッチ処理を使用して適切なデータパイプラインを構築する。一度に4M画像をRAMにロードしないでください。PyTorch DataLoader with num_workers > 0 for parallel loading. アクティブな学習を考慮して、あらゆる人間がラベルアップした画像の価値を最大化するようにしましょう. Each label should be selected strategically, not randomly. ストアおよびバージョンインベーディングだけでなく、原始画像のみです。4Mインベーディングの再抽出は400ユーロで、保存されたインベーディングのロードには費用がかかりません。 続きを読む: 続きを読む: MiniBatchKMeans (scikit-learn) — how to label smarter, not more Active learning overview MiniBatchKMeans(ミニバッチ) アクティブ学習概要 結論 Semi-supervised learning is not magic - it's engineering. You take the structure hidden in unlabeled data (via embeddings and clustering), turn it into approximate labels, and use those to give your supervised model a head start. The unlabeled data doesn't replace real labels - it supplements them. あなたは、非ラベル化されたデータに隠された構造(埋め込みやクラスタリングを通じて)を取って、それを近似のラベルに変え、監督されたモデルに頭を向けるためにそれらを使用します。 私たちが作ったパイプラインをまとめてみましょう: 探検 - 汚職、不一致のフォーマット、クラス不均衡のための10,000枚の画像をスキャンしました。 プレプロセッサ - すべての画像を ResNet50 が期待するフォーマットに標準化しました: 224×224, RGB, CLAHE 強化, ImageNet 標準化。 機能抽出 - プレトレーニングされた ResNet50 を使用して、それぞれの画像を視覚的本質をキャプチャする 2048 次元の埋め込みに変換しました。 Clustering — K-Means と DBSCAN を適用して、ラベルのない画像をクラスターにグループ化し、クラスターメンバーシップに基づいて偽のラベルを割り当てました。 Semi-supervised training — We pre-trained a CNN on 9,800 pseudo-labeled images, then fine-tuned on 200 real labels, and compared against a supervised-only baseline. 半監視されたトレーニング — われわれは、9800の偽のラベル化された画像にCNNをプレトレーニングし、その後、200の実際のラベルにフィンアウトした。 スケーリング分析 - 4M 画像の計算、ストレージ、ラベル化コストを推定し、5000 ユーロの予算内での実行可能性を確認しました。 キータイトル Takeaways: プレトレーニングされたCNNは、これまで訓練されていなかった画像ドメインでさえ、あらゆるイメージドメインから有意義な機能を抽出することができます。埋め込みのクラスタリングは、しばしば実際のクラスに匹敵する自然なグループ化を明らかにします。 Pseudo-label は不完全ですが、不完全なラベルで事前に訓練されたモデルは、実際のラベルでのみ訓練されたモデルを上回ります。 このパターンは、医療イメージング、産業品質管理、衛星画像、文書分類、生物多様性モニタリングなどの分野で機能しています。