導入
同じ手順で測ったはずなのに、測定した日や装置や担当者が違うだけで、データの分布がズレる。
この「ズレ」が解析や機械学習の結論を壊すことがあります。
これが バッチ効果(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)で一部特徴が少し変わる
- でも同時に:バッチ(測定回)ごとに全体がズレる(大きめ)
そして次を比較します:
-
補正なし
- PCAでバッチに分かれる
- ランダムCVは高いが、別バッチでは当たらない
-
シンプル補正(バッチごとの平均シフト除去)
- PCAが混ざる
- 別バッチでも当たりやすくなる
さらにオプションで:
-
設計が悪い例(バッチとラベルが絡む)
- 補正するとむしろ真の信号も消える/検証不能
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で色分け)
図2:補正前PCA(ラベルで色分け)
図3:補正後PCA(batchで色分け)
図4:補正後PCA(ラベルで色分け)
図5:CV比較(ランダム分割 vs Leave-one-batch-out)
設計が悪い例の図(confounded case)(図6:バッチ、図7:ラベル)
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)を示しました
まずはあなたのデータでも、
- PCAをbatch色で塗る
- GroupKFoldで評価する
この2つだけでも、結論の頑健性が大きく上がります。






