Lesson 8 / 12

モデル③ CT画像分類CNN(読み解き)

このレッスンで学ぶこと

  • DICOM前処理を組み込んだDatasetと、小さなCNNのコードを読み解く
  • 医用画像で患者単位のtrain/val/test分割をどう実装するか理解する
  • 医用画像でのデータ拡張の是非(左右反転が危険な理由)を知る
  • 画像モデルでも評価はレッスン5の指標(感度・特異度)で行うと確認する

1. このレッスンの位置づけ

レッスン7でDICOMを画像として扱えるようになりました。 このレッスンでは、その前処理を土台に「CT画像を分類するCNN」の全体像を、 実装コードを読み解きながらつかみます。

⚠ このレッスンは読み解き中心です: PyTorchはブラウザで動かないため、コードは実行せず読む形式です。 また、PyTorchの基本文法(nn.Module・学習ループ・zero_grad→backward→step)は E資格コーディング対策コースで学んだ前提で進め、 ここでは「医用画像だからこそ気をつけること」に集中します。 PyTorchが初めての方は、先にE資格コースのUnit 2〜3に目を通すことをおすすめします。

2. 全体像 — 画像分類AIのパイプライン

CT画像を分類するAIは、ざっくり次の流れで動きます。太字がこのレッスンで注目する部分です。

段階やること使う知識
① 前処理DICOM読込→HU変換→ウィンドウ→リサイズ→正規化レッスン7
分割患者単位でtrain/val/testに分けるレッスン2の画像版
③ Dataset/DataLoader1枚ずつ取り出し、バッチにまとめるE資格コース
④ CNN畳み込み層で画像の特徴を捉えるE資格コース
⑤ 学習ループzero_grad→forward→loss→backward→stepE資格コース
⑥ 評価感度・特異度・AUCで評価レッスン5

②の分割と①⑥の医用画像特有の扱いが、テーブルデータのときとの違いです。順に読み解いていきます。

3. Dataset — DICOM前処理を1枚ずつ行う

PyTorchの Dataset は「画像を1枚要求されたら、前処理して返す」係です。 レッスン7で学んだHU変換・ウィンドウ・リサイズ・正規化を、ここに詰め込みます。

dataset.py(PyTorch・読むだけ)
import numpy as np
import pydicom
import torch
import torch.nn.functional as F
from torch.utils.data import Dataset

SIZE = 128   # モデルに入れる画像サイズ(装置ごとに違う元サイズをそろえる)

def preprocess(path):
    # レッスン7の前処理:読込 → HU → ウィンドウ → 0〜1正規化
    ds = pydicom.dcmread(path)
    hu = ds.pixel_array * ds.RescaleSlope + ds.RescaleIntercept
    lo, hi = -600 - 750, -600 + 750   # 肺野条件 WL=-600, WW=1500
    img = np.clip(hu, lo, hi)
    img = (img - lo) / (hi - lo)          # 0〜1 に正規化
    return img.astype(np.float32)

class CTDataset(Dataset):
    def __init__(self, paths, labels):
        self.paths = paths        # DICOMファイルのパス一覧
        self.labels = labels      # 各画像のラベル(0/1)

    def __len__(self):
        return len(self.paths)

    def __getitem__(self, i):
        img = preprocess(self.paths[i])                    # (H, W)
        x = torch.from_numpy(img).unsqueeze(0)             # (1, H, W) チャネル次元を足す
        x = F.interpolate(x.unsqueeze(0), size=(SIZE, SIZE),
                          mode="bilinear", align_corners=False).squeeze(0)  # (1, SIZE, SIZE) にリサイズ
        y = torch.tensor(self.labels[i])
        return x, y

💡 unsqueeze(0) でチャネルを足す: CNNは入力を (チャネル, 高さ, 幅) の形で受け取ります。CTは白黒(1チャネル)なので、 (H, W) の画像に unsqueeze(0) で先頭に次元を足して (1, H, W) にします。 さらに F.interpolateSIZE×SIZE(ここでは128×128)にリサイズします—— 装置や施設によって元の画像サイズが違うので、モデルに入れる前にそろえておくのが定石です。 DataLoaderがバッチにまとめると (N, 1, H, W)—— E資格コースで学んだ (N, C, H, W) の形になります。

4. ★最重要 — 患者単位で分割する(画像版)

レッスン2の鉄則 「1人の患者のデータは訓練かテストの一方だけ」は、画像でこそ深刻です。 1回のCT検査から何十枚ものスライスが出るため、 スライス単位でランダムに分けると、同じ患者の隣り合うスライスが訓練とテストに割れ、 モデルは病気ではなく「その患者の体」を覚えてしまいます。

分割の前に、「どのファイルが・どのラベルで・どの患者のものか」を一覧にしておきます。 ここでは data/<患者ID>/<ラベル>/<スライス>.dcm というフォルダ構成を前提にしています (手元に実データが無い場合は、 ブログ記事「CT画像分類をPyTorchで」の 合成データ生成関数で代用できます)。

prepare.py(PyTorch・読むだけ)
from pathlib import Path

# フォルダ構成の例:data/<患者ID>/<ラベル>/<スライス>.dcm
#   data/P001/normal/0001.dcm  data/P001/normal/0002.dcm ...
#   data/P006/lesion/0001.dcm  ...
paths, labels, patient_ids = [], [], []
for p in sorted(Path("data").glob("*/*/*.dcm")):
    paths.append(str(p))
    labels.append(1 if p.parent.name == "lesion" else 0)   # 病変あり=1
    patient_ids.append(p.parts[-3])                          # 患者ID=フォルダ名

print("画像数:", len(paths), " 患者数:", len(set(patient_ids)))
print("病変あり:", sum(labels), " 病変なし:", len(labels) - sum(labels))
▼ 出力例(20患者×10スライスの場合)
画像数: 200  患者数: 20
病変あり: 100  病変なし: 100

patient_ids が次の分割で効いてきます。スライスごとに「誰の画像か」を持っておくことが、 患者単位で分けるための下準備です。

split.py(PyTorch・読むだけ)
from sklearn.model_selection import GroupShuffleSplit

# paths[i] の画像が「どの患者のものか」を patient_ids に持たせておく
# 例: patient_ids = ["P001", "P001", "P002", ...](スライスごとに患者ID)

gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
train_idx, test_idx = next(gss.split(paths, labels, groups=patient_ids))

# 同じ患者が train と test の両方に出ないことを確認
train_patients = set(patient_ids[i] for i in train_idx)
test_patients  = set(patient_ids[i] for i in test_idx)
print("患者の重複:", len(train_patients & test_patients))
▼ 出力
患者の重複: 0

レッスン2とまったく同じ GroupShuffleSplit を、今度はスライス画像に適用しています。 groups患者IDを渡すことで、患者ごとにまるごと train か test に振り分けられます。 「患者の重複: 0」が、正しく分割できた合図です。

⚠ 現場で最も多い過大評価の原因: 「精度99%の医用画像AIが、他院ではまったく使い物にならない」—— その原因の多くが、この患者リーケージです。開発の評価が信頼できるかは、 まず「どの単位で分割したか」を確認するところから始まります。

分割できたら、インデックスで CTDataset を2つ作り、DataLoader でバッチにまとめます。 学習用は shuffle=True、評価用は shuffle=False——ここもE資格コースと同じ型です。

loader.py(PyTorch・読むだけ)
from torch.utils.data import DataLoader

torch.manual_seed(0)   # 再現性のため乱数を固定

train_ds = CTDataset([paths[i] for i in train_idx], [labels[i] for i in train_idx])
test_ds  = CTDataset([paths[i] for i in test_idx],  [labels[i] for i in test_idx])

train_loader = DataLoader(train_ds, batch_size=16, shuffle=True)   # 学習:順序をシャッフル
test_loader  = DataLoader(test_ds,  batch_size=16, shuffle=False)  # 評価:順序は固定

x, y = next(iter(train_loader))
print("1バッチの形:", x.shape, " ラベル:", y.shape)
▼ 出力
1バッチの形: torch.Size([16, 1, 128, 128])  ラベル: torch.Size([16])

(N, C, H, W) = (16, 1, 128, 128)——バッチ16枚・1チャネル・128×128。 E資格コースで学んだ4次元テンソルの形が、DICOMから出発してもきちんと再現されています。

5. CNN本体と学習ループ(E資格コースの復習)

モデルは、畳み込み層で画像の特徴を捉える小さなCNNです。 構造の詳細はE資格コースのCNN回に譲り、ここでは全体の形を確認します。

model.py(PyTorch・読むだけ)
import torch.nn as nn

class SmallCNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(1, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),  # (N,16,H/2,W/2)
            nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),  # (N,32,H/4,W/4)
        )
        self.head = nn.Sequential(
            nn.AdaptiveAvgPool2d(1), nn.Flatten(),   # (N, 32)
            nn.Linear(32, 2),                        # 2クラス(正常/病変)
        )

    def forward(self, x):
        return self.head(self.features(x))
train.py(PyTorch・読むだけ)
model = SmallCNN()
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

model.train()
for epoch in range(5):
    for x, y in train_loader:              # x:(N,1,H,W)  y:(N,)
        optimizer.zero_grad()             # ① 勾配リセット
        out = model(x)                   # ② 順伝播
        loss = criterion(out, y)         # ③ 損失
        loss.backward()                 # ④ 逆伝播
        optimizer.step()                  # ⑤ 更新
    print(f"epoch {epoch}: loss={loss.item():.3f}")
▼ 出力例(値は学習のたびに変わります)
epoch 0: loss=0.702
epoch 1: loss=0.696
epoch 2: loss=0.695
epoch 3: loss=0.694
epoch 4: loss=0.693   # ← 上の合成データ(200枚)では、5エポックではほとんど下がらない

⚠ loss が 0.693 付近から動かないとき: 2クラス分類で loss ≈ 0.693(=ln 2)は、「どちらのクラスも確率0.5」=まだ何も学べていない状態を意味します。 この小さなCNNをゼロから学習させると、数百枚・数エポック程度ではここに留まることが珍しくありません。 主な原因は3つ——①データが少ない(数百枚はCNNには少なすぎる) ②病変が小さいAdaptiveAvgPool2d で特徴マップ全体を平均するため、小さな病変の信号が薄まる) ③学習が短い(エポック数・学習率)。 実務では ImageNet等で事前学習済みのモデル(ResNetなど)を転移学習するのが標準で、 スクラッチの小さなCNNはこのレッスンのように「パイプラインの型を理解する」ための足場と考えてください。

学習ループの骨格(zero_grad → forward → loss → backward → step)は、 テーブルデータでも画像でもまったく同じです。 E資格コースで身につけたこの型が、そのまま医用画像でも通用します。

6. ★医用画像のデータ拡張は「慎重に」

画像の深層学習では、データを水増しするデータ拡張(回転・反転・拡大など)がよく使われます。 しかし医用画像では、自然画像の常識をそのまま持ち込むと危険です。

拡張猫の写真では医用画像では
左右反転OK(左右どちらの猫も猫)⚠ 危険。心臓は左、肝臓は右。反転すると内臓の位置が入れ替わり、病態(内臓逆位)と区別できなくなる
強い変形・拡大縮小OK⚠ 病変の大きさや形が診断に直結するため、歪めると意味が変わる
わずかな回転・平行移動OK△ 撮影のばらつき範囲なら許容されることが多い
明るさ・コントラスト微調整OK△ ウィンドウ設定の範囲で慎重に

⚠ 原則:解剖学的な意味を壊さない範囲で: データ拡張は「その変形を加えても診断ラベルが変わらない」ものだけに限ります。 左右反転が典型的なNG例で、実際に反転で学習したモデルが左右の判断を誤る報告もあります。 「増やせるから増やす」ではなく、医学的に妥当な拡張だけを選ぶ——医用画像AIの鉄則です。

7. 評価は「レッスン5の指標」で

画像モデルでも、評価の考え方はレッスン5と同じです。 正解率だけでなく、感度・特異度・AUCで見ます。むしろ医用画像は 「病変ありの画像が少ない」不均衡になりやすいので、正解率の当てにならなさはより深刻です。

evaluate.py(PyTorch・読むだけ)
model.eval()                            # 評価モード(dropout等を止める)
probs, trues = [], []
with torch.no_grad():                   # 勾配計算を止める(評価では不要)
    for x, y in test_loader:
        p = torch.softmax(model(x), dim=1)[:, 1]  # 病変クラスの確率
        probs.extend(p.tolist())
        trues.extend(y.tolist())

# あとはレッスン5と同じ:閾値で感度・特異度、roc_auc_score で AUC
from sklearn.metrics import roc_auc_score
print("AUC:", roc_auc_score(trues, probs))

💡 model.eval()torch.no_grad() 評価時は model.eval() で dropout や BatchNorm を評価用の挙動に切り替え、 torch.no_grad() で勾配計算を止めます(メモリと速度の節約)。 この2つは評価コードの定番の組み合わせです(E資格コースのCNN学習回で既習)。 確率が出せれば、あとはレッスン5の感度・特異度・AUCがそのまま使えます。

8. 練習問題

問題 1

スライス単位で分割するとなぜダメ?

CT画像を「スライス1枚ずつ」ランダムに train/test 分割すると何が問題ですか。

  • A. スライスの枚数が足りなくなる
  • B. 同じ患者の隣接スライスが train と test に分かれ、患者を覚えるだけで高精度になる(患者リーケージ)
  • C. 画像が大きすぎてメモリに乗らない
答えと解説を見る

正解:B

1回の検査から出る何十枚ものスライスは互いによく似ています。スライス単位で分けると、 同じ患者のほぼ同じ画像が train と test の両方に入り、モデルは病変ではなく「その患者の体」を暗記して高精度に見えます。 患者単位(GroupShuffleSplit に患者IDを渡す)で分けるのが正解です。

問題 2

この拡張、使ってよい?

胸部CTの分類モデルの学習で「画像を左右反転して枚数を2倍にする」拡張を提案されました。採用すべきですか。

  • A. データが増えるので積極的に採用すべき
  • B. 内臓の左右が入れ替わり病態と区別できなくなるため、原則採用しない
  • C. 反転は画質を落とすので採用しない
答えと解説を見る

正解:B

心臓は左、肝臓は右——胸腹部CTでは左右に解剖学的な意味があります。 左右反転すると内臓の配置が入れ替わり、まれな病態(内臓逆位)と見分けがつかない画像を「正常」として学習させてしまいます。 ラベルの意味を変えない拡張だけを選ぶのが原則。左右反転は医用画像では典型的なNG例です。

問題 3

評価コードの2点セット

PyTorchで画像モデルを評価するとき、学習時と変えるべき2つの設定は何ですか。

答えと解説を見る

正解:model.eval()with torch.no_grad()

model.eval() で dropout・BatchNorm を評価用の挙動に切り替え、 torch.no_grad() で不要な勾配計算を止めます。 これを忘れると、dropoutが効いたまま評価してしまったり、無駄にメモリを使ったりします。 評価が済んで確率が得られれば、あとはレッスン5の感度・特異度・AUCがそのまま使えます。

9. まとめ

このレッスンのポイント

  • 画像分類AIは前処理→分割→Dataset→CNN→学習ループ→評価の流れ。骨格はテーブルデータと同じ
  • Datasetにレッスン7の前処理を組み込み、(1,H,W)のテンソルを返す
  • 患者単位で分割GroupShuffleSplit+患者ID)。スライス単位はリーケージで過大評価
  • データ拡張は慎重に。左右反転は内臓の左右を壊すので原則NG。ラベルの意味を変えない拡張だけ
  • 評価は感度・特異度・AUC(レッスン5)。model.eval()no_grad()で確率を出す

3つのモデルがそろいました。しかし「よく当たる」だけでは医療現場に置けません。 次のレッスンでは、「AIがなぜそう判断したか」を説明する技術——説明可能性(XAI)に進みます。

完了するとコース一覧に進捗が記録されます