2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

導入

同じ手順で測ったはずなのに、測定した日装置担当者が違うだけで、データの分布がズレる。
この「ズレ」が解析や機械学習の結論を壊すことがあります。

これが バッチ効果(batch effect)です。

  • 例(研究データあるある)
    • 試薬ロット、プレート、実験日、装置の個体差、校正のズレ
    • RNA-seq のライブラリ調製日、シーケンサ、フローセル/レーン
    • 質量分析のラン、カラムの劣化、オペレータ差
  • 例(実務あるある)
    • 工場のライン変更、センサ交換、ファーム更新
    • サービスのリリース、ログ形式変更、デプロイ環境の差

重要なのは、バッチ効果は「ランダムノイズ」ではなく、
系統的(systematic)に入るズレなので、放置すると“それっぽい結論”が簡単に出てしまうことです。

本記事では、ダミーデータで

  • PCAで「バッチで分離して見える」現象
  • “ランダム分割のCVは良いのに、別バッチだと当たらない”現象
  • シンプルな補正(平均シフト除去)で改善する例
  • ただし設計が悪いと補正で救えない例

を、Google Colabで再現します。


TL;DR

  • バッチ効果は「測り方の違い」で生まれる系統誤差で、解析を壊す。
  • PCA/UMAPを batch色 で塗ると、まず疑える(最初にやるべきチェック)。
  • 機械学習では ランダム分割CVが過大評価になりやすい。
    → 実運用や再現性を見るなら Leave-one-batch-out(GroupKFold) が重要。
  • 解析側の補正(ComBat等)もあるが、まずは「バッチをまたいで比較できる設計」が最強。
  • バッチとラベル(条件)が完全に絡んだ設計は、後処理では救えない。

1. バッチ効果とは(学部生向けに)

バッチ効果を一言でいうと:

「本当に見たい差(生物差・材料差・処方差)ではなく、
測定条件の違いでデータに入るズレ」

です。

1.1 何が困る?

  • PCA/クラスタリングが 生物群ではなくバッチで分かれる
  • 差の検出(統計・機械学習)が バッチ差を“真の差”と勘違いする
  • 別日に再測定したら結果が再現しない(再現性が崩れる)

1.2 何が原因?

  • 平均のズレ(オフセット)
  • 分散のズレ(ばらつき)
  • 非線形のズレ(装置の応答特性の変化)
  • 欠損や検出限界の違い(0埋め、LODなど)

2. 今日の最小実験:何を示したいか

このデモでは、特徴量が50個ある“オミクスっぽい”データを作ります(実データではありません)。

  • 真に見たい信号:ラベル(0/1)で一部特徴が少し変わる
  • でも同時に:バッチ(測定回)ごとに全体がズレる(大きめ)

そして次を比較します:

  1. 補正なし
    • PCAでバッチに分かれる
    • ランダムCVは高いが、別バッチでは当たらない
  2. シンプル補正(バッチごとの平均シフト除去)
    • PCAが混ざる
    • 別バッチでも当たりやすくなる

さらにオプションで:

  1. 設計が悪い例(バッチとラベルが絡む)
    • 補正するとむしろ真の信号も消える/検証不能

3. Google Colabで動かす(コピペでOK)

以下を上から順に実行してください。
fig/に図が保存されます。


セル1:準備

import os
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from sklearn.decomposition import PCA
from sklearn.model_selection import StratifiedKFold, GroupKFold, cross_val_score
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
from sklearn.linear_model import LogisticRegression

SEED = 42
rng = np.random.default_rng(SEED)

os.makedirs("fig", exist_ok=True)

セル2:ダミーデータ生成(バランス良い設計/悪い設計)

def generate_batch_effect_data(
    n_batches=3,
    n_per_batch=200,
    n_features=50,
    n_informative=6,
    label_effect=2.0,
    batch_shift=4.0,
    seed=42,
    confounded=False,
):
    """
    confounded=False: 各バッチでラベル比が概ね50/50(望ましい設計)
    confounded=True : バッチごとにラベル比が偏る(望ましくない設計)
    """
    rng = np.random.default_rng(seed)
    X_list, y_list, b_list = [], [], []

    informative_idx = np.arange(n_informative)

    for b in range(n_batches):
        if confounded:
            # バッチとラベルが絡む(例:バッチ0はほぼ0、バッチ2はほぼ1)
            ps = [0.1, 0.5, 0.9] if n_batches == 3 else [0.2]*n_batches
            p = ps[b]
        else:
            p = 0.5

        y = rng.binomial(1, p, size=n_per_batch)

        # ベースのノイズ(各特徴量は標準正規から)
        X = rng.normal(0, 1, size=(n_per_batch, n_features))

        # 真の信号:ラベル1で informative features が上がる
        X[:, informative_idx] += y[:, None] * label_effect

        # バッチ効果:特徴量ごとのオフセット(大きめ)
        shift = rng.normal(0, batch_shift, size=n_features)
        X += shift

        # バッチ効果:スケールも少し変わる(現場っぽさ)
        scale = rng.normal(1.0, 0.1, size=n_features)
        X *= scale

        X_list.append(X)
        y_list.append(y)
        b_list.append(np.full(n_per_batch, b))

    X_all = np.vstack(X_list)
    y_all = np.concatenate(y_list)
    batch = np.concatenate(b_list)

    df_meta = pd.DataFrame({
        "y": y_all,
        "batch": batch,
    })
    return X_all, y_all, batch, df_meta

X, y, batch, meta = generate_batch_effect_data(confounded=False, seed=SEED)
meta["batch"].value_counts(), meta["y"].mean()

セル3:PCAで可視化(色を変えて「何で分かれているか」を見る)

def plot_pca(X, meta, color_col, title, savepath):
    pipe = make_pipeline(StandardScaler(), PCA(n_components=2, random_state=SEED))
    Z = pipe.fit_transform(X)
    df = meta.copy()
    df["pc1"] = Z[:,0]
    df["pc2"] = Z[:,1]

    plt.figure(figsize=(6.8,5.6))
    for key, g in df.groupby(color_col):
        plt.scatter(g["pc1"], g["pc2"], s=18, alpha=0.8, label=str(key))
    plt.xlabel("PC1"); plt.ylabel("PC2")
    plt.title(title)
    plt.legend(title=color_col, bbox_to_anchor=(1.02, 1), loc="upper left")
    plt.tight_layout()
    plt.savefig(savepath, dpi=160, bbox_inches="tight")
    plt.show()
    print("Saved:", savepath)

# 補正前:batchで色分け
plot_pca(X, meta, "batch", "PCA (before correction): colored by batch", "fig/fig1_pca_before_by_batch.png")

# 補正前:ラベルで色分け
plot_pca(X, meta, "y", "PCA (before correction): colored by label y", "fig/fig2_pca_before_by_label.png")

セル4:機械学習評価(ランダム分割 vs Leave-one-batch-out)

ポイント:

  • ランダム分割(StratifiedKFold):バッチが混ざるので「同じ分布」でテストしやすい
  • Leave-one-batch-out(GroupKFold):別バッチに一般化できるかを見る(研究でも実務でも重要)
clf = make_pipeline(
    StandardScaler(),
    LogisticRegression(max_iter=800, solver="lbfgs")
)

cv_random = StratifiedKFold(n_splits=5, shuffle=True, random_state=SEED)
cv_group  = GroupKFold(n_splits=len(np.unique(batch)))

acc_random = cross_val_score(clf, X, y, cv=cv_random).mean()
acc_group  = cross_val_score(clf, X, y, cv=cv_group, groups=batch).mean()

print("=== Before correction ===")
print("Random CV (mix batches):        ", acc_random)
print("Leave-one-batch-out (GroupKFold):", acc_group)

セル5:シンプル補正(バッチごとの平均シフトを除去)

ここでは最も直感的な補正をします:

  • 各特徴量について
    X_corrected = X - mean(batch) + mean(all)

このデモでは「各バッチの平均との差(mean(batch))は、そのバッチに含まれるデータから推定できる」という前提で補正しています(ラベルyは使いません)。
つまり運用としては「新しいバッチがある程度まとまって手元に来るので、バッチ単位で中心化できる」ケースを想定しています。
新バッチが1点しか来ない/平均を推定できない場合は、ここまで綺麗に改善しないことがあります。

def correct_batch_mean_shift(X, batch):
    X = np.asarray(X)
    global_mean = X.mean(axis=0)
    Xc = X.copy()
    for b in np.unique(batch):
        idx = (batch == b)
        batch_mean = X[idx].mean(axis=0)
        Xc[idx] = X[idx] - batch_mean + global_mean
    return Xc

Xc = correct_batch_mean_shift(X, batch)

# 補正後:batchで色分け
plot_pca(Xc, meta, "batch", "PCA (after mean-shift correction): colored by batch", "fig/fig3_pca_after_by_batch.png")

# 補正後:ラベルで色分け
plot_pca(Xc, meta, "y", "PCA (after mean-shift correction): colored by label y", "fig/fig4_pca_after_by_label.png")

セル6:補正後の評価(ランダム分割 vs Leave-one-batch-out)

acc_random_c = cross_val_score(clf, Xc, y, cv=cv_random).mean()
acc_group_c  = cross_val_score(clf, Xc, y, cv=cv_group, groups=batch).mean()

print("=== After correction (mean-shift) ===")
print("Random CV (mix batches):        ", acc_random_c)
print("Leave-one-batch-out (GroupKFold):", acc_group_c)

# 可視化(棒グラフ)
labels = ["Random CV\n(before)", "Group CV\n(before)", "Random CV\n(after)", "Group CV\n(after)"]
vals   = [acc_random, acc_group, acc_random_c, acc_group_c]

plt.figure(figsize=(7.5,4.2))
plt.bar(labels, vals)
plt.ylim(0, 1.05)
plt.title("Accuracy comparison: random split vs leave-one-batch-out")
plt.ylabel("Accuracy")
plt.tight_layout()
plt.savefig("fig/fig5_accuracy_comparison.png", dpi=160, bbox_inches="tight")
plt.show()
print("Saved: fig/fig5_accuracy_comparison.png")

セル7:設計が悪い例(バッチとラベルが絡むと救えない)

「患者群は全部バッチ2、対照群は全部バッチ0」みたいな状況です。
このとき、モデルは“生物差”ではなく“バッチ差”で当てます。
補正すると、今度は信号も一緒に消えたり、検証不能になります。

X_bad, y_bad, batch_bad, meta_bad = generate_batch_effect_data(confounded=True, seed=SEED)

print("Label rate by batch (confounded case):")
print(meta_bad.groupby("batch")["y"].mean())

# 補正前の評価
acc_random_bad = cross_val_score(clf, X_bad, y_bad, cv=cv_random).mean()
acc_group_bad  = cross_val_score(clf, X_bad, y_bad, cv=GroupKFold(n_splits=3), groups=batch_bad).mean()

# 補正
X_bad_c = correct_batch_mean_shift(X_bad, batch_bad)
acc_random_bad_c = cross_val_score(clf, X_bad_c, y_bad, cv=cv_random).mean()
acc_group_bad_c  = cross_val_score(clf, X_bad_c, y_bad, cv=GroupKFold(n_splits=3), groups=batch_bad).mean()

print("=== Confounded case ===")
print("Random CV before:", acc_random_bad)
print("Group  CV before:", acc_group_bad)
print("Random CV after :", acc_random_bad_c)
print("Group  CV after :", acc_group_bad_c)

# PCAも確認(任意)
plot_pca(X_bad, meta_bad, "batch", "PCA (confounded): colored by batch", "fig/fig6_pca_confounded_by_batch.png")
plot_pca(X_bad, meta_bad, "y", "PCA (confounded): colored by label y", "fig/fig7_pca_confounded_by_label.png")

セル8:図のzip化

import shutil
shutil.make_archive("figures", "zip", "fig")
print("Created figures.zip")

4. 実行結果の図

以下のURL部分を、Qiitaにアップロードした画像URLに置き換えてください。

図1:補正前PCA(batchで色分け)

fig1_pca_before_by_batch.png

図2:補正前PCA(ラベルで色分け)

fig2_pca_before_by_label.png

図3:補正後PCA(batchで色分け)

fig3_pca_after_by_batch.png

図4:補正後PCA(ラベルで色分け)

fig4_pca_after_by_label.png

図5:CV比較(ランダム分割 vs Leave-one-batch-out)

fig5_accuracy_comparison.png

設計が悪い例の図(confounded case)(図6:バッチ、図7:ラベル)

fig6_pca_confounded_by_batch.png
fig7_pca_confounded_by_label.png


5. 結果の読み方

5.1 PCAを「batch色」で塗るのが最初の一手(今回の結果)

  • 図1では、点群が batchごとにほぼ完全に3つの塊へ分離しています。
    つまりこのデータでは、補正前は「バッチ差」が主成分(PC)を強く支配しています。
  • 図2を見ると、各バッチの塊の中で y=0/1が混ざっています。
    これは「ラベル差(見たい信号)」が存在しても、補正前はバッチ差に埋もれて見えにくいことを示します。
  • そして補正後の図4で初めて、yがはっきり分離して見えるようになります(= 見たい信号が前面に出た)。

5.2 ランダムCVが良くても安心できない(今回の結果)

今回の結果が、まさに「ランダム分割CVは過大評価になりやすい」典型例です。

  • 補正前:Random CV は 0.978 と高い一方、Group CV は 0.485 とほぼチャンスレベル(図5)
    • ランダム分割では学習・評価の両方に同じバッチが混ざるため、「バッチ差を覚える」だけでも当たりやすくなります。
    • しかし Leave-one-batch-out(GroupKFold)では、別バッチへ一般化できずに性能が落ちます。

この落差こそが「運用や再現性を意識するなら、GroupKFoldで評価すべき」理由です。

5.3 シンプル補正でも「救えるケース」はある(今回の結果)

今回の実行結果では、mean-shift補正後に次の変化がはっきり確認できました。

  • 図3:batchで色分けしても点がよく混ざり、バッチ分離が大きく弱まった
  • 図4:yで色分けすると 2群が明確に分離し、ラベル差(見たい信号)が前面に出た
  • 図5:Group CV が 0.485 → 0.982 に改善し、別バッチでも当たるようになった

これは、このデモでは主なズレが「バッチごとの平均シフト(オフセット)」であり、
その成分を除くことで「本当に見たい差」を見やすくできたことを示唆します。


6. でも注意:バッチ補正は万能ではない

6.1 設計が悪いと救えない(confounded case)

バッチとラベルが絡んでいる(confounded)と、

  • どこまでが「本当に見たい差」で、どこまでが「測定条件の差」かを
    データだけでは切り分けにくくなります

この場合、補正が逆効果になることがあります(図6/7)。
(このデモ設定でも、confounded例では補正後に精度が下がる方向に働きやすく、
「補正で真の信号まで削ってしまう」リスクを体感できます。)

結局のところ、confoundingを根本から避けるには 実験設計(ランダム化・混在配置・QC/橋渡しサンプル)が最重要です。

6.2 “補正したデータ”をそのまま下流解析に使うときの注意

  • 補正は、下流(差分解析、クラスタリング、ML)に影響します
  • 手法によっては、分散構造や相関が変わることがあります

研究用途では「目的に合う補正」を選び、
可能なら 元データでも整合性を確認するのが安全です。


7. 研究室運営・実務で効くチェックリスト

7.1 実験設計(最強)

  • ラベル(条件)がバッチに偏らないように ランダム化
  • プレートや日をまたぐなら ブロック設計(各ブロックに各条件を混在)
  • 橋渡しサンプル(同一サンプルを各バッチで測る)やQCを入れる
  • メタデータ(日時、装置、担当、ロット)を必ず残す

7.2 解析(最初にやる)

  • PCA/UMAPを batch色label色 で両方描く
  • 差が怪しい特徴量(QC指標、総量、欠損率)を確認
  • バッチ補正(ComBat/Harmony等)や「batchを共変量に入れる」ことを検討

7.3 機械学習(やりがち落とし穴)

  • CVは「ランダム」だけでなく GroupKFold(バッチ単位)もやる
  • 前処理(標準化・補正)は 学習データだけでfit するのが原則(リーク対策)
  • 「別バッチでも当たる」ことを成功条件に入れる

まとめ

バッチ効果は研究データでも実務データでも、再現性と意思決定を壊す“定番の落とし穴”です。

本記事では、

  • PCAでバッチ分離を可視化し
  • ランダムCVの落とし穴を示し
  • Leave-one-batch-outで一般化性能を確認し
  • シンプル補正で改善する例と、救えない例(confounded)を示しました

まずはあなたのデータでも、

  1. PCAをbatch色で塗る
  2. GroupKFoldで評価する

この2つだけでも、結論の頑健性が大きく上がります。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?