Python環境を起動中... 初回のみ数秒かかります
Lesson 2 / 12

医療データの扱いと落とし穴

このレッスンで学ぶこと

  • 医療データが要配慮個人情報として特別に扱われる理由が分かる
  • 医療AI最大の落とし穴「患者リーケージ」を実験で体験する
  • GroupShuffleSplit患者単位の分割ができるようになる
  • クラス不均衡があると正解率が当てにならないことを確かめる

1. 医療データは「特別扱い」が前提

モデルを作り始める前に、医療データそのものの性質を知っておきましょう。 カルテ(診療記録)や検査値、医用画像といった医療データには、病歴が含まれます。 病歴は日本の個人情報保護法で「要配慮個人情報」—— 本人の同意なく取得してはならず、特に慎重な扱いが求められる情報——に位置づけられています。

だから医療AIの開発では、データに触れる前の段階で次のような手当てが行われます (制度の全体像はレッスン10で扱います。ここでは言葉だけ覚えておきましょう)。

💡 開発者の感覚として: 「もらったデータをとりあえず手元のPCにコピー」は医療では通用しません。 誰のデータを・何の目的で・どこで・いつまで使うかが決められているのが医療データです。 本コースで使うのは、教育用に公開されたデータセットと合成データ(プログラムで作った架空データ)のみです。

2. 最大の落とし穴 —「同じ患者」が訓練とテストに混ざる

ここからが本題です。医療データには、一般のデータにはあまりない、重要な構造があります。 それは「1人の患者から、複数のデータが取られる」ことです。

このデータを機械学習入門コースで学んだ train_test_split でそのまま行ごとにランダム分割すると、何が起きるでしょうか。 同じ患者のよく似たデータが、訓練にもテストにも入ります。 モデルは病気のパターンではなく「その患者の顔(データの癖)」を覚えるだけで、 テストで高得点が取れてしまう——これが患者リーケージ (リーケージ=本来テストまで隠しておくべき情報が訓練側に漏れること)です。

ランダム分割では同じ患者のレコードが訓練とテストに混ざるが、患者単位の分割では患者ごとにどちらか一方に入る図
左:行ごとのランダム分割では、同じ患者(同じ行)のレコードが train と test に割れてしまう。
右:患者単位の分割では、1人の患者のデータはどちらか一方にしか入らない

⚠ 医療AIデータ分割の鉄則: 1人の患者のデータは、訓練かテストのどちらか一方だけに入れる。 モデルの本来の仕事は「学習で見たことのない患者」を正しく判定することだからです。

3. 実験 — リーケージをわざと起こしてみる

言葉で聞くより、起こして見るのが一番です。 まず「患者20人 × 各10レコード」の合成データ(架空の患者データ)を作ります。 ポイントは2つ——患者ごとの個人差(体質)は大きく病気かどうかの本当の手がかりは特徴量0の小さな差だけ、という設計です。

sample_1.py
Ctrl+Enter
出力

💡 コードの読みどころ — 2行のデータ生成を分解する

このセルの核心は次の2行です。ここが分かると、あとの実験がすべて腹落ちします。

base = rng.normal(0, 1.5, size=(n_pat, 5))
X = base[groups] + rng.normal(0, 0.2, size=(n_pat * n_rec, 5))

1行目:患者ごとの「体質」を1人1本だけ作る

rng.normal(0, 1.5, size=(n_pat, 5)) は、平均0・標準偏差1.5の乱数を 20行×5列ぶん作ります。1行が1人の患者、5列が5つの特徴量です。 これは「その人がもともと持っている検査値の傾向」——つまり個人差(体質)のイメージで、 病気かどうかとはまったく関係ありません

2行目前半:体質を、その患者の全レコードにコピーする

base[groups] がこのコードで一番のポイントです。 groups[0,0,...,0, 1,1,...,1, ...] という 200個の患者IDが入った配列でした。これを添字に使うと、 「0番目の行を10回、1番目の行を10回…」と並べ直した200行の配列が返ってきます。

変数形(shape)意味
base(20, 5)患者20人ぶんの体質(1人1行)
groups(200,)各行が誰のものかを示す患者ID
base[groups](200, 5)体質を各患者の10レコードに複製したもの

つまり同じ患者の10行は、ほぼ同じ値になります。 これがのちほど「モデルが患者を覚えてしまう」原因になります。 (この添字の書き方はNumPyのファンシーインデックスと呼ばれる技法です)

2行目後半:レコードごとの小さな揺らぎを足す

+ rng.normal(0, 0.2, size=(n_pat * n_rec, 5)) は、200行すべてに 標準偏差0.2の小さなノイズを足しています。 同じ患者でも測定のたびに値は少しブレる、という現実を再現するためです。 完全に同じ値のままだと、実データからかけ離れてしまいます。

そして最後の1行が「本当の信号」

X[:, 0] = X[:, 0] + y * 1.5 で、病気の患者(y=1)だけ 特徴量0を1.5だけ底上げしています。これが唯一、病気と結びついた手がかりです。

設計の狙い:信号より個人差を大きくする

体質のばらつき(標準偏差1.5)に対して、病気の信号(+1.5)は 5つの特徴量のうち1つだけ。しかも記録ごとの揺らぎ(0.2)はごく小さい。 この組み合わせにより、モデルにとっては 「病気の手がかりを学ぶ」よりも「患者の体質を覚える」ほうが簡単になります。 現実の医療データでも、個人差が疾患の影響より大きいことは珍しくありません。 次のセルで、この設計がリーケージを生む様子が見えます。

では、このデータを行ごとにランダム分割して、ランダムフォレストを学習させてみましょう。

sample_2.py
Ctrl+Enter
出力

正解率 1.0(100%)。完璧なモデルができた——ように見えます。 しかしこのモデルが覚えたのは、病気のパターンではなく患者一人ひとりの体質(データの癖)です。 テストデータの大半は「訓練で見たことのある患者」の別レコードなので、 体質を照合するだけで正解できてしまうのです。

4. 患者単位の分割 — GroupShuffleSplit

scikit-learn には、グループ(ここでは患者)単位でデータを分割する GroupShuffleSplit が用意されています。 使い方は train_test_split に似ていますが、groups(患者ID)を渡すのがポイントです。

sample_3.py
Ctrl+Enter
出力

結果は──正解率 0.783。 テストの6人(患者 0, 1, 5, 8, 15, 17)は訓練で一度も見ていない患者なので、 モデルは体質の暗記が通用せず、「本当の信号」だけで勝負することになります。 これが、このモデルの実力です。

分割方法正解率意味
行ごとのランダム分割1.000患者の暗記込みの見せかけの数字
患者単位の分割0.783未知の患者に対する実力

💡 next(gss.split(...)) の読み方 — 3段階に分けて理解する

train_test_split に慣れていると、この2行は少し面食らいます。順番に分解します。

段階1:GroupShuffleSplit(...) は「分割のルール」を決めるだけ

gss = GroupShuffleSplit(n_splits=1, test_size=0.3, random_state=42)

この時点ではまだ何も分割されていません。「テストは3割」「パターンは1通り」という 設定を持った道具を用意しただけです。データもまだ渡していません。

段階2:.split(...) は「行番号の組」を順に生み出す装置を返す

gss.split(X, y, groups=groups)

ここで初めてデータを渡します。ただし返ってくるのは分割済みのデータではなく(train_idx, test_idx) という行番号のペアを順に取り出せる装置(イテレータ)です。 n_splits=5 にすれば5通りのペアを順に出せる、という作りになっています。

段階3:next(...) で最初の1組を取り出す

train_idx, test_idx = next(gss.split(X, y, groups=groups))

今回は n_splits=1 なのでペアは1組だけ。next() でその1組を取り出し、 左辺の2つの変数に分けて受け取っています。

なお、for train_idx, test_idx in gss.split(...): と書いても同じことができます。 1組しか使わないときは next()、複数パターンを回すときは for、 と使い分けると読みやすくなります。

いちばん大事な注意:返るのは「データ」ではなく「行番号」

train_test_split は分割済みのデータ(X_train など)をそのまま返しますが、 こちらが返すのは行番号の配列です。そのため、 X[train_idx]y[train_idx] のように自分で添字を使って取り出す必要があります。 ここを忘れると train_idx をそのままモデルに渡してしまい、意図しない動作になります。

📌 実務では GroupShuffleSplit だけではありません

このレッスンでは、仕組みが分かりやすい GroupShuffleSplit を使いました。 ただし実際の研究や開発では、目的に応じて次のような手法を使い分けます。 「患者をまたがせない」という原則は同じで、分け方の戦略が違うと考えてください。

手法特徴向いている場面
GroupShuffleSplit 患者単位でランダムに1回(または任意回数)分ける まず手早く傾向をつかみたいとき
GroupKFold 患者単位でK分割し、全データが1度ずつテストに回る データが少なく、評価を安定させたいとき
StratifiedGroupKFold 患者をまたがせず、かつ各分割のクラス比率も揃える 陽性が少ない(不均衡な)医療データ

医療データは症例数が限られ、かつ陽性が少ないことが多いため、 実務では StratifiedGroupKFold が選ばれる場面が少なくありません。 1回の分割だけだと、たまたま難しい患者がテストに偏って結果が振れることがあるからです。 交差検証そのものはレッスン11で詳しく扱います。

⚠ 現場ではさらに: 「同じ患者」だけでなく、同じ検査・同じ装置・同じ施設のデータが訓練とテストに混ざることでも、 似たようなゲタ(過大評価)を履くことがあります。 画像AIでの患者単位分割はレッスン8で、施設間の性能差はレッスン11で扱います。

5. もう1つの落とし穴 — クラス不均衡と「正解率のワナ」

医療データのもう1つの現実は、病気の人のデータは健康な人よりずっと少ないことです (クラス不均衡と呼びます)。 このとき「正解率」という指標は、驚くほど簡単に人を騙します。 「全員陰性です」と答えるだけの手抜きモデルを作って確かめてみましょう。

sample_4.py
Ctrl+Enter
出力

正解率90%——なのに、病気の人は1人も見つけていません。 陽性が10%しかいないデータでは、「全員陰性」と言うだけで正解率90%が出てしまうのです。 医療AIで正解率だけを見てはいけない理由が、この6行に詰まっています。

💡 では何を見ればいい?: 「病気の人をどれだけ拾えたか(感度)」「健康な人をどれだけ正しく陰性にできたか(特異度)」を 分けて見る必要があります。これがレッスン5の主役です。 また、分割時に stratify=y を付けて訓練とテストの陽性率を揃えるのは 機械学習入門コースの復習ですが、医療データでは特に重要になります。

6. 練習問題

問題 1

患者単位の分割に直そう

次のコードはこのままだとエラーになります。患者単位の分割になるように1箇所修正して実行してください (まず一度そのまま実行して、エラーメッセージを読んでみるのがおすすめです)。

exercise_1.py
出力
ヒントを見る(答え+解説)
gss = GroupShuffleSplit(n_splits=1, test_size=0.3, random_state=42)
train_idx, test_idx = next(gss.split(X, y, groups=groups))   # groups を渡す

print("重複患者数:", len(np.intersect1d(groups[train_idx], groups[test_idx])))  # 0

GroupShuffleSplit は「何を1グループとみなすか」を知らないと分割できないため、 groups を渡さないと The 'groups' parameter should not be None というエラーになります。 修正後は重複患者数が 0 になります。

問題 2

どちらの数字を報告する?

今日の実験で、同じモデルに対して「正解率 1.000(ランダム分割)」と「正解率 0.783(患者単位分割)」という 2つの数字が得られました。医療AIの性能として報告すべきなのはどちらで、それはなぜですか。

  • A. 1.000 ── せっかく高い数字が出たのだから、そちらを使うべき
  • B. 0.783 ── 未知の患者に対する性能を表しているから
  • C. 2つの平均(約0.89)を報告するのが公平
答えと解説を見る

正解:B

医療AIが現場で相手にするのは、学習時には存在しなかった新しい患者です。 その状況を正しく真似ているのは患者単位分割の 0.783 だけで、 1.000 は「訓練で見た患者をもう一度当てただけ」の見せかけの数字です。平均にも意味はありません。 評価は本番の使われ方に似せる——医療AI評価の基本原則です。

問題 3

テスト患者を増やすとどうなる?

sample_3 のコードで test_size=0.30.5 に変えると (=患者10人で学習し、患者10人でテスト)、正解率はどうなるでしょうか。予想してから実行してみてください。

exercise_3.py
出力
ヒントを見る(答え+解説)
gss = GroupShuffleSplit(n_splits=1, test_size=0.5, random_state=42)

実行すると正解率は 0.48 まで下がります(テスト患者10人)。 訓練に使える患者が10人しかいないと、5つの特徴量から本当の信号を学び切れないためです。 「患者数が少ない」ことがどれだけモデルを弱くするか—— 症例数の限られる医療AI開発が常に向き合う課題を、ミニチュアで体験できます。

7. まとめ

このレッスンのポイント

  • 病歴を含む医療データは要配慮個人情報。匿名化・仮名化と決められた手続きのもとで扱う
  • 医療データは1人の患者から複数のレコードが生まれる構造を持つ
  • 行ごとのランダム分割は患者リーケージを起こし、性能を過大評価する(実験では 1.000 vs 0.783)
  • 鉄則:1人の患者のデータは、訓練かテストのどちらか一方へ。実装は GroupShuffleSplit(...).split(X, y, groups=患者ID)
  • 医療データはクラス不均衡が普通。「全員陰性」でも正解率90%——正解率だけで判断しない(→ レッスン5へ)

データの落とし穴を体験したところで、次のレッスンからいよいよモデル①「乳がん診断支援」の開発が始まります。 まずは、データを知るところから。

8. 自由練習

このページの環境は自由に使えます。信号の強さ(y * 1.5 の部分)や個人差の大きさ(0, 1.5)を 変えると、2つの分割方法の差はどう変わるでしょうか。いろいろ試してみましょう。

free_practice.py
出力

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