病理画像におけるファウンデーションモデルの埋め込みによる検索:自然言語クエリを用いてTCGA-BRCA乳がんの類似組織を検索する
病理スライドは、ホールスライド画像(WSI)であり、それぞれが数ギガバイトの大きさになることがあります。病理医は、各症例について、低倍率と高倍率で繰り返しズームイン・ズームアウトを行い、数十分かけて観察します。しかし、それでも「この症例と類似した組織パターンを持つ過去の症例はありますか?」「腫瘍浸潤境界に密なリンパ球浸潤を持つ症例のみを抽出してください」のような質問に対する答えを見つけることは困難です。本記事では、TCGA-BRCA(The Cancer Genome Atlas、浸潤性乳がん)乳がん病理データセットの100スライドを用いて、病理のファウンデーションモデル(Virchow2、UNI、CONCH)を用いてWSIタイルを埋め込み、大規模な類似性検索インデックスを構築し、自然言語クエリを用いた検索を可能にする、実用的なパイプラインを実演します。
📚 推奨される前提知識(強く推奨)
本記事は、高度なAI×生物学のハードコアセッションです。開始する前に、以下のDryBenchセッションを視聴することを強くお勧めします。
- DryBench ai-native #3 TransformersとEmbeddings
- DryBench ai-native #5 Attention Mechanism
- DryBench ai-native #13 HuggingFaceと商用API
前提知識なしで開始すると、本セッションで説明するVision Transformer(ViT)、コントラスト学習、CLIPスタイルのテキスト-画像アライメント、およびHuggingFace transformers visionモデルのロードに関する実践的なパターンについて、改めて説明しないため、理解が難しくなる可能性があります。
DryBenchで学んだこと
DryBench ai-native #3では、Transformerの埋め込みは、ドメインやモダリティに依存しない「学習された潜在空間」であることを学び、#5では、アテンションが画像内の任意の2つのパッチ間の関係を学習し、それがビジョン・トランスフォーマーの基礎となることを学びました。#13では、HuggingFaceがビジョンモデル(ViT、CLIPなど)のロードと推論のための標準APIを提供していることを学びました。
病理学は、ビジョン・トランスフォーマーが特に効果を発揮する分野です。自然画像と比較して、非常に異なる視覚的特徴(支配的な赤色のヘマトキシリンと紫色のエオシン色素による染色、細胞、組織、および上皮層構造、倍率によって異なる情報層)を持っており、大量の病理スライド(数百万のWSIと数十億のタイル)のコーパスで事前学習されたファウンデーションモデルは、さまざまなダウンストリームタスク(がん分類、グレード予測、生存予測、および類似性検索)に直接使用できます。このセッションは、それらのファウンデーション埋め込みを「検索ツール」として使用する実践的な応用であり、特に、CONCHとPLIPのコントラスト学習を活用して、画像と自然言語を同じ空間でマッチングさせます。
ハードコアな問題定義
実践的なシナリオ:TCGA-BRCA乳がん病理の類似性検索
病院(またはTCGA-BRCA公開サブセット)の100スライド規模の乳がん病理アーカイブにおいて、以下のことが可能になるようにします。
- 画像によるクエリ:新しい症例の特定のタイル(例:腫瘍浸潤境界)→ 過去の症例から、最も類似した組織パターンの上位10症例を検索。
- テキストによるクエリ:「腫瘍境界に密なリンパ球浸潤を持つ浸潤性乳管がん」などの自然言語クエリ→ 該当する所見を示す症例を検索。
- クロス機関の類似性:他の病院のデータとのマッチング(マルチサイト研究)。
- 処理速度:1つのWSI(約1000万タイル)の埋め込みインデックスを30分以内に作成し、クエリの応答時間を100ms以内にする。
既存のアプローチのスペクトル
- 手動で設計された特徴量(カラーヒストグラム、Haralick、LBP):小規模な検索には可能ですが、病理学に特有の階層的な情報を反映していません。
- ImageNetで事前学習されたResNet-50:自然画像で学習されており、病理学とのドメインシフトが大きいため、パフォーマンスが制限されます。
- CTransPath(2022年):初期の病理学に特化した自己教師ありViT。ベースライン。
- UNI(2024年、Mass General Brigham + Harvard):10万枚のWSIと1億枚以上のタイルで学習し、強力な類似性検索パフォーマンスを実現[1]。
- Virchow(Paige.AI 2024年):150万枚のWSIで学習し、複数の臨床ダウンストリームタスクで最先端のパフォーマンスを発揮[2]。Virchow2(2024年のフォローアップ)は、パフォーマンスと効率を向上させています。
- PLIP(2023年、Nature Medicine):病理×言語のコントラスト学習で、医療Twitter + PubMedペアで学習し、自然言語クエリをサポート[3]。
- CONCH(2024年):大規模な病理-言語モデルで、PLIPのフォローアップであり、臨床キャプションで学習し、強力なゼロショット分類能力を備えています[4]。
- PANTHER(2024年、PLIPのフォローアップ):多言語および多地域病理-言語モデル。
- GigaPath(2024年、Microsoft + Providence):WSI全体のレベルでの埋め込み[5]。
このセッションでは、Virchow2(基本的な画像埋め込み)とCONCH(自然言語のブリッジ)を組み合わせて使用します。
このセッションの目標メトリック
- TCGA-BRCAの100 WSIサブセット(約1億のタイル)からタイル埋め込みインデックスを構築します。
- クエリ(病理医がラベル付けしたゴールドスタンダードと比較)に対して、上位Kのリコールを0.80以上達成します。
- 自然言語クエリに対して、Recall@10(R@10)を0.55以上達成します(CONCHのベンチマークを参照)。
- クエリの応答時間を100ms未満(FAISS HNSWを使用)に達成します。
- カラー正規化(MacenkoまたはVahadane)の前後のパフォーマンスの差を測定します。
必要なツールとインフラ
| ツール | 役割 | ライセンス |
|---|---|---|
| Virchow2 (paige-ai HuggingFace) | 病理画像埋め込みの基盤 | 学術利用は無料(Paigeのポリシー) |
| UNI (MahmoodLab HuggingFace) | 代替手段/アンサンブルパートナー | 学術利用は無料 |
| CONCH (MahmoodLab HuggingFace) | 病理 × 言語のコントラスティブ学習 | 学術利用は無料 |
| PLIP | コントラスティブな代替手段 | 学術利用は無料 |
| OpenSlide | WSIファイルの読み込み(SVS、NDPI、MRXSなど) | LGPL |
| FAISS | 大規模KNNインデックス(IVF-PQ、HNSW) | MIT |
| HuggingFace transformers、timm | モデルのロード、画像処理バックボーン | Apache 2.0 |
| Macenko/Vahadane (staintools) | 色正規化 | MIT |
| pyvips(オプション) | 非常に大きなWSIのメモリ効率の良い処理 | GPL v3 |
| Chroma、Qdrant(オプション) | メタデータフィルタリングの代替手段 | Apache 2.0 |
インフラの要件:
- 埋め込みの抽出: 16〜32GB以上のVRAMを備えたサーバーGPU(Virchow2とUNIは8GBでfp16で実行できますが、より大きなバッチサイズの場合は16GB以上を推奨します)。
- FAISSインデックス: CPUで十分です。32GB以上のRAM(1億個のタイル × 1024次元のfloat16 = 約2GB、1億個のタイルの規模ではIVF-PQ圧縮が必要です)。
- ディスク: 病院のストレージまたはNAS上のTCGAストレージ、埋め込みインデックスは約2〜10GB(規模によって異なります)。
学習者のための概算費用: ローカルGPUを使用する場合は、API費用は発生しません。クラウドを使用する場合は、時間ごとの料金表を参照してください。TCGAの100個のWSIのサブセットの埋め込みとインデックス作成には約2〜4時間かかります(RTX 4090 24GBに基づいて概算)。
パイプラインの実用的な実装
全体のフロー:
ステップ1. WSIタイリング
WSIはピラミッド構造(複数の解像度レベル)を持っています。埋め込みのためには、20倍の倍率(0.5 µm/ピクセル)の224×224タイルが標準です。TCGAは主に40倍(0.25 µm/ピクセル)でスキャンされています。
from pathlib import Pathfrom dataclasses import dataclass, asdictfrom typing import Iterator
import openslideimport numpy as npfrom PIL import Image
@dataclassclass TileMetadata: wsi_id: str slide_index: int level: int x_wsi: int # 元のWSIレベルの座標 y_wsi: int x_selected: int # 選択されたレベル(20倍)の座標 y_selected: int size: int tissue_ratio: float
def open_wsi(wsi_path: Path) -> openslide.OpenSlide: try: return openslide.OpenSlide(str(wsi_path)) except openslide.OpenSlideError as e: raise RuntimeError(f"WSI {wsi_path} を開くのに失敗しました: {e}")
def compute_target_level(slide: openslide.OpenSlide, target_mpp: float = 0.5) -> tuple[int, float]: """20倍の倍率(0.5 mpp)に最も近いレベルを選択します。""" mpp_x = float(slide.properties.get(openslide.PROPERTY_NAME_MPP_X, 0.25)) downsample = target_mpp / mpp_x level = slide.get_best_level_for_downsample(downsample) return level, slide.level_downsamples[level]
def compute_tissue_ratio(tile: np.ndarray) -> float: """HSV彩度に基づいて組織比率を計算します。背景(白、低い彩度)を除外します。
実際には、彩度に対してOtsuの閾値処理を追加することを推奨します。 """ from skimage.color import rgb2hsv hsv = rgb2hsv(tile) saturation = hsv[..., 1] tissue_mask = saturation > 0.05 return float(tissue_mask.mean())
def tile_wsi( wsi_path: Path, wsi_id: str, slide_index: int, tile_size: int = 224, target_mpp: float = 0.5, tissue_threshold: float = 0.2, output_dir: Path | None = None, save_tiles: bool = False,) -> Iterator[tuple[TileMetadata, np.ndarray]]: """WSIを224×224のタイルにグリッド状に分割し、自動的に背景を除外します。
ジェネレーターを使用することでストリーミング処理となり、メモリ使用量を最小限に抑えることができます。 """ slide = open_wsi(wsi_path) level, level_downsample = compute_target_level(slide, target_mpp) width, height = slide.level_dimensions[level] print(f"[{wsi_id}] level={level}, dims={width}x{height}, mpp≈{target_mpp}")
for y in range(0, height - tile_size, tile_size): for x in range(0, width - tile_size, tile_size): # 元のレベルの座標 x_wsi = int(x * level_downsample) y_wsi = int(y * level_downsample) try: tile_pil = slide.read_region((x_wsi, y_wsi), level, (tile_size, tile_size)).convert("RGB") except openslide.OpenSlideError: continue tile_np = np.asarray(tile_pil) tissue_ratio = compute_tissue_ratio(tile_np) if tissue_ratio < tissue_threshold: continue meta = TileMetadata( wsi_id=wsi_id, slide_index=slide_index, level=level, x_wsi=x_wsi, y_wsi=y_wsi, x_selected=x, y_selected=y, size=tile_size, tissue_ratio=tissue_ratio, ) if save_tiles and output_dir: out = output_dir / f"{wsi_id}_x{x}_y{y}.png" out.parent.mkdir(parents=True, exist_ok=True) tile_pil.save(out, format="PNG") yield meta, tile_np slide.close()ステップ2. 色正規化(Macenko)
異なる病院で異なるH&E染色プロトコルやスキャナーベンダーが使用されているため、色が見た目上で異なることがあります。Macenkoアルゴリズムが標準です。
def macenko_normalize( tiles: list[np.ndarray], target_stain_matrix: np.ndarray | None = None, target_max_conc: np.ndarray | None = None, alpha: float = 1.0, beta: float = 0.15,) -> tuple[list[np.ndarray], np.ndarray, np.ndarray]: """Macenko色正規化。
target_stain_matrix / target_max_concがNoneの場合、最初のタイルから学習します。 実際には、staintoolsまたはtorchstainライブラリを使用します。 """ try: from torchstain import MacenkoNormalizer except ImportError: raise RuntimeError("torchstainが必要です(pip install torchstain)") import torch
normalizer = MacenkoNormalizer(backend="numpy") if target_stain_matrix is None: # ターゲットを最初のタイルから学習 normalizer.fit(np.transpose(tiles[0], (2, 0, 1))) else: normalizer.HERef = target_stain_matrix normalizer.maxCRef = target_max_conc
normalized = [] for tile in tiles: try: tile_t = np.transpose(tile, (2, 0, 1)) norm_t, _, _ = normalizer.normalize(tile_t, stains=False) normalized.append(np.transpose(np.asarray(norm_t), (1, 2, 0))) except Exception: normalized.append(tile) # 正規化に失敗した場合は、元のタイルを保持します return normalized, normalizer.HERef, normalizer.maxCRefステップ3. Virchow2 バッチ埋め込み抽出
import torchfrom transformers import AutoImageProcessor, AutoModel
class Virchow2Embedder: """Paige.AI Virchow2(またはUNI)病理学的基礎埋め込み。"""
MODEL_ID = "paige-ai/Virchow2"
def __init__(self, device: str = "cuda", torch_dtype: torch.dtype = torch.float16): self.device = device self.processor = AutoImageProcessor.from_pretrained(self.MODEL_ID) self.model = AutoModel.from_pretrained( self.MODEL_ID, torch_dtype=torch_dtype, ).to(device).eval() # モデル構成でVirchow2のhidden_dimを確認します(実行時)。 self.hidden_dim = getattr(self.model.config, "hidden_size", 1280)
@torch.no_grad() def embed_batch(self, tiles: list[np.ndarray], batch_size: int = 32) -> np.ndarray: """(N, 224, 224, 3) uint8 → (N, hidden_dim) fp16。""" if not tiles: return np.zeros((0, self.hidden_dim), dtype=np.float16) embeddings = [] for i in range(0, len(tiles), batch_size): chunk = tiles[i:i + batch_size] inputs = self.processor(images=chunk, return_tensors="pt").to(self.device) outputs = self.model(**inputs) # 標準のCLSトークン埋め込みを使用します。一部のモデルでは平均プーリングを推奨しています。 cls_emb = outputs.last_hidden_state[:, 0, :] embeddings.append(cls_emb.cpu().numpy()) return np.concatenate(embeddings, axis=0).astype(np.float16)
def stream_and_embed_wsi( wsi_path: Path, wsi_id: str, slide_index: int, embedder: Virchow2Embedder, normalize_color: bool = True, batch_size: int = 32,) -> tuple[np.ndarray, list[TileMetadata]]: """1つのWSIをストリーミング処理し、バッチ埋め込みを抽出します → (N, dim), メタデータ。""" tile_buffer: list[np.ndarray] = [] meta_buffer: list[TileMetadata] = [] all_embeddings: list[np.ndarray] = [] all_metadata: list[TileMetadata] = []
for meta, tile in tile_wsi(wsi_path, wsi_id, slide_index): tile_buffer.append(tile) meta_buffer.append(meta) if len(tile_buffer) >= batch_size: if normalize_color: tile_buffer, _, _ = macenko_normalize(tile_buffer) emb = embedder.embed_batch(tile_buffer, batch_size=batch_size) all_embeddings.append(emb) all_metadata.extend(meta_buffer) tile_buffer, meta_buffer = [], [] # 残りのバッファをフラッシュします if tile_buffer: if normalize_color: tile_buffer, _, _ = macenko_normalize(tile_buffer) emb = embedder.embed_batch(tile_buffer, batch_size=batch_size) all_embeddings.append(emb) all_metadata.extend(meta_buffer)
if not all_embeddings: return np.zeros((0, embedder.hidden_dim), dtype=np.float16), [] return np.concatenate(all_embeddings, axis=0), all_metadataステップ4. FAISS IVF-PQ 大規模インデックス
100万個のタイル(Virchow2 hidden_dim ≈ 1280、float16)=約2.5GBのメモリ内。1億個のタイル規模の場合、IVF-PQ(積量子化)による圧縮とオンディスクのmmapが不可欠です。
import faiss
def build_ivf_pq_index( embeddings: np.ndarray, nlist: int = 4096, # クラスタ数(スケール依存:100万 → 4k、1億 → 65k) m: int = 32, # サブ量子化器の数(PQ) nbits: int = 8, # 1つのサブ量子化器あたりのビット数 training_sample: int = 100_000,) -> faiss.Index: """IVF-PQ:大規模圧縮インデックス。
hidden_dim=1280 × 100万 × float32 = 5GB → PQを使用すると、約40MBに圧縮されます。 """ dim = embeddings.shape[1] quantizer = faiss.IndexFlatIP(dim) index = faiss.IndexIVFPQ(quantizer, dim, nlist, m, nbits, faiss.METRIC_INNER_PRODUCT) # トレーニングサンプル(データセット全体が大きすぎる場合は、ランダムに10万個を使用します) sample_size = min(training_sample, len(embeddings)) sample_idx = np.random.choice(len(embeddings), sample_size, replace=False) train_sample = embeddings[sample_idx].astype(np.float32) faiss.normalize_L2(train_sample) print(f"IVF-PQトレーニング開始(nlist={nlist}、m={m}、サンプル={sample_size})") index.train(train_sample) print("トレーニング完了") return index
def build_hnsw_index( embeddings: np.ndarray, m: int = 32, ef_construction: int = 200,) -> faiss.Index: """HNSW:優れた精度と速度、比較的大きなメモリ。100万以下のスケールに推奨されます。
""" dim = embeddings.shape[1] index = faiss.IndexHNSWFlat(dim, m, faiss.METRIC_INNER_PRODUCT) index.hnsw.efConstruction = ef_construction embs = embeddings.astype(np.float32) faiss.normalize_L2(embs) index.add(embs) return index
def add_embeddings_in_chunks( index: faiss.Index, embeddings_iter: Iterator[np.ndarray], chunk_size: int = 100_000,) -> None: """大規模な埋め込みをチャンク単位でインデックスに追加します(メモリ管理)。""" for chunk in embeddings_iter: chunk_f32 = chunk.astype(np.float32) faiss.normalize_L2(chunk_f32) index.add(chunk_f32)
def save_index_mmap(index: faiss.Index, path: Path) -> None: """MMAPでアクセスできるインデックスを保存します。""" faiss.write_index(index, str(path))
def load_index_mmap(path: Path) -> faiss.Index: """MMAPで読み込みます(メモリ節約)。""" return faiss.read_index(str(path), faiss.IO_FLAG_MMAP | faiss.IO_FLAG_READ_ONLY)ステップ5. クエリ—画像検索
def search_by_image( query_tile: np.ndarray, embedder: Virchow2Embedder, index: faiss.Index, metadata: list[TileMetadata], k: int = 20, nprobe: int = 128, # IVFプローブの数 ef_search: int = 128, # HNSW efSearch) -> list[dict]: """1つのクエリタイル→上位K件の類似タイルメタデータ。""" query_emb = embedder.embed_batch([query_tile]).astype(np.float32) faiss.normalize_L2(query_emb) if hasattr(index, "nprobe"): index.nprobe = nprobe if hasattr(index, "hnsw"): index.hnsw.efSearch = ef_search distances, indices = index.search(query_emb, k) results = [] for dist, idx in zip(distances[0], indices[0]): if idx < 0 or idx >= len(metadata): continue m = metadata[idx] results.append({ **asdict(m), "similarity": float(dist), }) return resultsステップ6. クエリ—自然言語検索(CONCH)
CONCHは、画像エンコーダとテキストエンコーダを共有の埋め込み空間に整合させます。自然言語クエリ→画像検索。
from transformers import CLIPModel, CLIPProcessor
class CONCHTextEncoder: """CONCHテキストエンコーダのラッパー。"""
MODEL_ID = "MahmoodLab/CONCH"
def __init__(self, device: str = "cuda"): self.device = device self.processor = CLIPProcessor.from_pretrained(self.MODEL_ID) self.model = CLIPModel.from_pretrained(self.MODEL_ID).to(device).eval()
@torch.no_grad() def encode_text(self, text: str) -> np.ndarray: inputs = self.processor(text=[text], return_tensors="pt", padding=True).to(self.device) text_emb = self.model.get_text_features(**inputs) return text_emb.cpu().numpy()
def search_by_text( query_text: str, text_encoder: CONCHTextEncoder, index: faiss.Index, # CONCH画像エンコーダの埋め込みを使用して一致させる必要があります metadata: list[TileMetadata], k: int = 20,) -> list[dict]: """自然言語クエリ→類似の画像タイル。
注:このインデックスは、CONCH画像エンコーダの埋め込みを使用して作成する必要があります。 Virchow2画像インデックスとは、埋め込み空間が異なります。 """ query_emb = text_encoder.encode_text(query_text).astype(np.float32) faiss.normalize_L2(query_emb) distances, indices = index.search(query_emb, k) return [ {**asdict(metadata[i]), "similarity": float(d)} for d, i in zip(distances[0], indices[0]) if 0 <= i < len(metadata) ]**重要:**画像検索用のインデックスと、テキスト-画像クロス検索用のインデックスは異なります。実際には、Virchow2画像インデックスとCONCH画像インデックスの両方を維持する必要がある場合があります(RAMとディスクの使用量が2倍になります)。
ステップ7. 統合パイプライン—100件のTCGA-BRCA WSIで実行
def full_pipeline( wsi_paths: list[Path], work_dir: Path, device: str = "cuda",) -> tuple[faiss.Index, list[TileMetadata]]: """WSIリスト→FAISSインデックス+メタデータ。""" work_dir.mkdir(parents=True, exist_ok=True) embedder = Virchow2Embedder(device=device) all_metadata: list[TileMetadata] = [] all_embeddings: list[np.ndarray] = []
for i, wsi_path in enumerate(wsi_paths): wsi_id = wsi_path.stem print(f"[{i+1}/{len(wsi_paths)}] {wsi_id}") try: emb, meta = stream_and_embed_wsi( wsi_path, wsi_id=wsi_id, slide_index=i, embedder=embedder, normalize_color=True, ) all_embeddings.append(emb) all_metadata.extend(meta) print(f" タイル数: {len(meta)}") except Exception as e: print(f" 失敗: {e}") continue
if not all_embeddings: raise RuntimeError("埋め込み結果がありません") concat_emb = np.concatenate(all_embeddings, axis=0) print(f"合計タイル数: {len(concat_emb)}, 次元: {concat_emb.shape[1]}")
# スケールに基づいてインデックスの種類を決定します if len(concat_emb) < 500_000: print("HNSWインデックス(50万未満のスケール)") index = build_hnsw_index(concat_emb) else: print("IVF-PQインデックス(50万以上のスケール)") index = build_ivf_pq_index(concat_emb) add_embeddings_in_chunks(index, [concat_emb])
save_index_mmap(index, work_dir / "wsi_index.faiss") # メタデータも保存します(msgpack、jsonlなど) import json with open(work_dir / "metadata.jsonl", "w") as f: for m in all_metadata: f.write(json.dumps(asdict(m)) + "\n") print(f"保存先: {work_dir}") return index, all_metadata
# 例の実行(TCGA-BRCA 100 WSIシナリオ)# tcga_wsi_dir = Path("/data/tcga_brca_svs")# wsi_files = sorted(tcga_wsi_dir.glob("*.svs"))[:100]# index, meta = full_pipeline(wsi_files, work_dir=Path("./tcga_brca_output"))ステップ8. 自然言語クエリの例
病理医が実際に検索に使用する可能性のある自然言語クエリの例。CONCHが理解できる形式で記述されています。
EXAMPLE_QUERIES = [ "浸潤性乳管癌、腫瘍辺縁に密度の高いリンパ球浸潤", "高悪性度の腫瘍、壊死中心、顕著な核小体", "管状癌、整った腺構造", "隣接する正常な乳腺組織、小葉構造が保たれている", "髄様癌、細胞質性の成長パターンとTIL", "乳管内癌(DCIS)、小葉状構造", "線維芽細胞の増殖により腫瘍巣の周囲に形成された間質性線維症",]
def demo_text_queries( text_encoder: CONCHTextEncoder, conch_index: faiss.Index, metadata: list[TileMetadata], output_dir: Path,) -> None: """セット内の各クエリについて、上位5件の結果を保存します。""" output_dir.mkdir(parents=True, exist_ok=True) import json for query in EXAMPLE_QUERIES: results = search_by_text(query, text_encoder, conch_index, metadata, k=5) out_path = output_dir / f"query_{hash(query) % 10**6}.json" with open(out_path, "w") as f: json.dump({"query": query, "top_5": results}, f, ensure_ascii=False, indent=2)
## 性能、コスト、および既知の失敗事例
### 性能の参考(公開されているベンチマークを引用)
| モデル | ベンチマーク | 線形プロービング AUC | 検索 R@10 | 出典 ||------|------|:------------------:|:-------------:|------|| ImageNet ResNet-50 | Camelyon16 | 0.83 | 0.32 | 既存のベースライン || CTransPath | Camelyon16 | 0.91 | 0.51 | Wang et al., MedIA 2022 || UNI | Camelyon16 + BRACS | 0.95+ | 0.68 | Chen et al., Nat Med 2024 [1] || Virchow | マルチコホート | 0.94~0.97 | 0.70 | Vorontsov et al., Nat Med 2024 [2] || Virchow2 | マルチコホート | 0.96~0.98(推定) | 0.72(推定) | Paige.AI 2024 [2] || PLIP | ゼロショット分類 | 0.87 | 0.45(テキストクエリ) | Huang et al., Nat Med 2023 [3] || CONCH | ゼロショット | 0.90 | 0.60(テキストクエリ) | Lu et al., Nat Med 2024 [4] || GigaPath(WSIレベル) | マルチタスク | 0.88~0.94 | — | Xu et al., Nature 2024 [5] |
### 学習者が再現するための推定コスト
- APIコスト:0(完全にローカル)。- 16~32GB VRAMサーバーGPUを想定し、約100のTCGA-BRCA WSI埋め込みをインデックス化するには、約2~4時間かかります。- FAISS IVF-PQインデックスのトレーニング:100万ベクトルのサンプルでトレーニングすると、10~20分かかります(CPU)。- 自然言語クエリへの応答:100ms未満(インデックスのmmap)。
### 5つの既知の失敗事例(コミュニティと論文から収集)
1. **異なる病院データからのドメインシフト(スキャナー/染色法の違い)** 症状:病院Aのデータでトレーニングおよびインデックス化されたモデルは、病院BのWSIで検索パフォーマンスが大幅に低下します(R@10 0.72 → 0.42)。 原因:H&E染色プロトコル、スキャナーベンダー(Aperio、Hamamatsu、Leica)、およびカラーコレクションの違い。Reinhard正規化だけでは不十分です。 対策:(a)Macenko/Vahadaneカラー正規化(このセクションのステップ2)、(b)複数の病院のデータでファインチューニング、(c)染色拡張を使用、(d)ドメイン敵対的トレーニング、(e)CONCHとUNIは、多様な病院データでトレーニングされているため、ドメインシフトに対して比較的堅牢です。 出典:Ciompi et al. "The importance of stain normalization in colorectal tissue classification" ISBI 2017; UNIベンチマークの議論 [1]。
2. **WSIファイル形式の互換性(SVS、NDPI、MRXS、isyntax)** 症状:OpenSlideは特定のベンダーのファイルを読み取れません(例:Philips isyntax)。 原因:OpenSlideは、一部の商用形式のサポートが限られています。 対策:(a)isyntaxの場合、Philips SDKまたは個別の変換ツール(bfconvert、isyntax-cli)を使用します、(b)MRXSの場合、OpenSlideの最新バージョンを使用します、(c)TIAToolboxを代替として使用します(OpenSlide +拡張機能)、(d)DICOM WSI(DICOM-WG 26標準)に変換します。 出典:OpenSlide GitHub Issues [6]; TIAToolboxドキュメント [7]。
3. **FAISS IVF-PQトレーニングデータの偏り** 症状:PQコードブックが特定の組織型の表現が多すぎる状態でトレーニングされた場合、他の組織型を検索する精度が大幅に低下します。 原因:トレーニングサンプルのランダム性が不十分、または特定のケースに偏っている。 対策:(a)トレーニングサンプルを複数の病院と組織型にわたって層化する、(b)`nprobe`を大きな値(256+)に設定して精度を確保する、(c)新しい組織型を追加するときにインデックスを再トレーニングする、(d)50万件未満のデータセットの場合、HNSWを優先します。 出典:FAISS Wiki "IVF training pitfalls" [8]。
4. **CONCHテキストクエリと画像インデックスのマッチングの失敗(エンコーダー空間の不一致)** 症状:Virchow2で作成された画像インデックスに対してCONCHテキスト埋め込みでクエリを実行すると、結果が無意味になります。 原因:Virchow2とCONCHは異なるトレーニング方法を使用しており、埋め込み空間が異なります。コントラスト学習によるアラインメントは行われていません。 対策:(a)自然言語クエリの場合、CONCH画像エンコーダーを使用して個別のインデックスを作成します、(b)デュアルインデックス(Virchow2画像、CONCH画像+テキスト)を維持します、(c)ストレージ/検索コストと精度のトレードオフを評価します。 出典:CONCH GitHubの使用例 [9]; コントラスト学習の基本原則。
5. **大規模なTCGA WSIのダウンロードとストレージの負担** 症状:1000件以上のTCGA-BRCA WSIの合計サイズは数TBです。学習者は、ローカルにダウンロードする際に時間とディスクスペースの制約に直面します。 原因:各TCGA WSIの平均サイズは500MBから2GBです。 対策:(a)TCGAのサブセットを使用します(例:完全なデータセットの代わりに100枚のスライドを使用します)、(b)GDC(Genomic Data Commons)APIを使用して、ストリーミング処理を行い、インデックスのみを保存し、元のデータを削除します、(c)クラウドストレージ(S3、GCS)から直接処理します、(d)学術クラウドコンソーシアムを利用します(例:NCI Cloud Resource)。 出典:TCGA / GDC公式ドキュメント [10]。
## 拡張のアイデア
- **スライドレベルの埋め込み:** タイル埋め込みをアテンションプーリング(例:ABMIL、GigaPath)を使用してスライドレベルの埋め込みに集約し、スライドレベルの検索と分類を行います。セクション12 Geneformerアプローチと同様の考え方です。- **マルチモーダル融合:** 晩期融合を使用して、WSIと臨床データ(年齢、ステージ、変異)を組み合わせて、検索結果を再ランキングします。- **生存予測:** 検索によって見つかった類似した症例から結果を収集し、新しい症例の予後を推定します。- **連合検索:** 複数の病院のデータをエクスポートすることなく、埋め込みのみを共有し、連合類似性検索インデックスを作成できるようにします。- **QuPathプラグイン:** 病理医がQuPathで領域を選択できるようにし、自動的に類似の症例を検索して結果を表示します。- **自然言語による再ランキング:** 上位100件の画像検索候補を、自然言語クエリとの関連性に基づいて再ランキングします。
## 次のセクション
- セクション03 `cellpose-sam-segmentation`: このセクションのWSIタイルと、個々の細胞セグメンテーション、および細胞レベルの埋め込みを組み合わせます。- セクション10 `med-llm-reproduction`: CONCHとPLIPのベンチマークを再現するためのフレームワークを提供します。- セクション12 `single-cell-perturbation`: 病理画像と単一細胞データを統合します(空間トランスクリプトミクスへのリンク)。- セクション14 `bio-mcp-agent`: 病理検索をMCPツールとして公開し、「この症例に類似した症例を見つける」などのタスクを自律的に実行できるようにします。
## 参考文献
1. Chen RJ, Ding T, Lu MY, et al. "Towards a general-purpose foundation model for computational pathology (UNI)." Nature Medicine 2024. `https://www.nature.com/articles/s41591-024-02857-3`2. Vorontsov E, Bozkurt A, Casson A, et al. "A foundation model for clinical-grade computational pathology (Virchow)." Nature Medicine 2024. `https://www.nature.com/articles/s41591-024-03141-0` / Virchow2のフォローアップリリース情報: `https://huggingface.co/paige-ai/Virchow2`3. Huang Z, Bianchi F, Yuksekgonul M, et al. "A visual-language foundation model for pathology image analysis using medical Twitter (PLIP)." Nature Medicine 2023. `https://www.nature.com/articles/s41591-023-02504-3`4. Lu MY, Chen B, Williamson DFK, et al. "A visual-language foundation model for computational pathology (CONCH)." Nature Medicine 2024. `https://www.nature.com/articles/s41591-024-02856-4`5. Xu H, Usuyama N, Bagga J, et al. "A whole-slide foundation model for digital pathology from real-world data (GigaPath)." Nature 2024. `https://www.nature.com/articles/s41586-024-07441-w`6. OpenSlide GitHub Issues: `https://github.com/openslide/openslide/issues`7. TIAToolboxドキュメント: `https://tia-toolbox.readthedocs.io/`8. FAISS Wiki (IVF-PQチューニング): `https://github.com/facebookresearch/faiss/wiki`9. HuggingFace MahmoodLab CONCH: `https://huggingface.co/MahmoodLab/CONCH`10. TCGA / GDCデータポータル: `https://portal.gdc.cancer.gov/`11. Camelyon16チャレンジデータセット: `https://camelyon16.grand-challenge.org/`12. QuPath(オープンソースの病理ビューア): `https://qupath.github.io/`13. Macenko M et al. "A method for normalizing histology slides for quantitative analysis." ISBI 2009.14. Vahadane A et al. "Structure-Preserving Color Normalization and Sparse Stain Separation for Histological Images." IEEE TMI 2016.15. torchstain (Macenkoの実装): `https://github.com/EIDOSLAB/torchstain`16. FAISS GitHub: `https://github.com/facebookresearch/faiss`17. HuggingFace paige-ai/Virchow2: `https://huggingface.co/paige-ai/Virchow2`18. HuggingFace MahmoodLab UNI: `https://huggingface.co/MahmoodLab/UNI`19. DICOM WSI (WG 26規格): `https://dicom.nema.org/`20. staintools (代替のカラー正規化): `https://github.com/Peter554/StainTools`