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/DataLoader | 1枚ずつ取り出し、バッチにまとめる | E資格コース |
| ④ CNN | 畳み込み層で画像の特徴を捉える | E資格コース |
| ⑤ 学習ループ | zero_grad→forward→loss→backward→step | E資格コース |
| ⑥ 評価 | 感度・特異度・AUCで評価 | レッスン5 |
②の分割と①⑥の医用画像特有の扱いが、テーブルデータのときとの違いです。順に読み解いていきます。
3. Dataset — DICOM前処理を1枚ずつ行う
PyTorchの Dataset は「画像を1枚要求されたら、前処理して返す」係です。
レッスン7で学んだHU変換・ウィンドウ・リサイズ・正規化を、ここに詰め込みます。
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.interpolate で SIZE×SIZE(ここでは128×128)にリサイズします——
装置や施設によって元の画像サイズが違うので、モデルに入れる前にそろえておくのが定石です。
DataLoaderがバッチにまとめると (N, 1, H, W)——
E資格コースで学んだ (N, C, H, W) の形になります。
4. ★最重要 — 患者単位で分割する(画像版)
レッスン2の鉄則 「1人の患者のデータは訓練かテストの一方だけ」は、画像でこそ深刻です。 1回のCT検査から何十枚ものスライスが出るため、 スライス単位でランダムに分けると、同じ患者の隣り合うスライスが訓練とテストに割れ、 モデルは病気ではなく「その患者の体」を覚えてしまいます。
分割の前に、「どのファイルが・どのラベルで・どの患者のものか」を一覧にしておきます。
ここでは data/<患者ID>/<ラベル>/<スライス>.dcm というフォルダ構成を前提にしています
(手元に実データが無い場合は、
ブログ記事「CT画像分類を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))
画像数: 200 患者数: 20
病変あり: 100 病変なし: 100
patient_ids が次の分割で効いてきます。スライスごとに「誰の画像か」を持っておくことが、
患者単位で分けるための下準備です。
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資格コースと同じ型です。
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回に譲り、ここでは全体の形を確認します。
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))
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で見ます。むしろ医用画像は 「病変ありの画像が少ない」不均衡になりやすいので、正解率の当てにならなさはより深刻です。
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. 練習問題
スライス単位で分割するとなぜダメ?
CT画像を「スライス1枚ずつ」ランダムに train/test 分割すると何が問題ですか。
- A. スライスの枚数が足りなくなる
- B. 同じ患者の隣接スライスが train と test に分かれ、患者を覚えるだけで高精度になる(患者リーケージ)
- C. 画像が大きすぎてメモリに乗らない
答えと解説を見る
正解:B
1回の検査から出る何十枚ものスライスは互いによく似ています。スライス単位で分けると、
同じ患者のほぼ同じ画像が train と test の両方に入り、モデルは病変ではなく「その患者の体」を暗記して高精度に見えます。
患者単位(GroupShuffleSplit に患者IDを渡す)で分けるのが正解です。
この拡張、使ってよい?
胸部CTの分類モデルの学習で「画像を左右反転して枚数を2倍にする」拡張を提案されました。採用すべきですか。
- A. データが増えるので積極的に採用すべき
- B. 内臓の左右が入れ替わり病態と区別できなくなるため、原則採用しない
- C. 反転は画質を落とすので採用しない
答えと解説を見る
正解:B
心臓は左、肝臓は右——胸腹部CTでは左右に解剖学的な意味があります。 左右反転すると内臓の配置が入れ替わり、まれな病態(内臓逆位)と見分けがつかない画像を「正常」として学習させてしまいます。 ラベルの意味を変えない拡張だけを選ぶのが原則。左右反転は医用画像では典型的なNG例です。
評価コードの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)に進みます。
完了するとコース一覧に進捗が記録されます