Kaggle Playground Series S4E8「Binary Prediction of Poisonous Mushrooms」に挑戦しました。 キノコの特徴量から食用(edible)か毒(poisonous)かを当てる二値分類コンペです。
今回は M365 Copilotでweb調査して戦略ドキュメントを作成 → Claude Codeで実装・実行 という 2段階のワークフローで進めました。仕上げのスライドはNotebook LMで作成し、PowerPointで微調整しています。
最終スコア:Private LB 0.98497 / Public LB 0.98515(Top 15%、Top1%まで残り+0.00007)

- 1. 外部データは使っていいの?
- 2. 勝敗を分けた施策:The Alpha Matrix
- 3. EDA:評価指標MCCの罠
- 4. データランドスケープ:均衡なクラスと、隠されたノイズ
- 5. 「欠損」という名の構造的シグナル
- 6. ゲームチェンジャー:Primary Dataによる「毒性」の数値化
- 7. 次元削減が暴く真の構造:t-SNE vs UMAP
- 8. 診断マトリックス:K-means(k=3)が導き出した3つの世界
- 9. ボトルネックの正体:「未知の19.5%」に潜む限界
- 10. 特徴量エンジニアリング・パイプライン
- 11. 単一モデル・バトル:OOF MCCとハードウェアの相性
- 12. アンサンブル戦略:異なるアーキテクチャによる多様性の注入
- 13. O(n log n)の魔法:MCC閾値のベクトル化最適化
- 14. 成長の軌跡:Top 15%到達へのマイルストーン
- 15. 結論と次なる課題
外部データは使っていいの?
このコンペの公式ルール(Section 7.C “External Data”)では、外部データの利用が明示的に許可されています。
“You may use data other than the Competition Data (“External Data”) to develop and test your Submissions. However, you will ensure the External Data is publicly available to use by all participants of the Competition for purposes of the competition at no cost to the other participants.”
条件は「全参加者が無料で利用できる、公開されたデータであること」。今回使ったUCI Machine Learning RepositoryのSecondary Mushroom DatasetとPrimary Datasetはどちらも無料公開データなので、 ルールに準拠しています。ちなみにこのコンペはPlayground Series(賞金なし、ランキングポイント・メダル対象外)です。
勝敗を分けた施策:The Alpha Matrix
M365 Copilotで作った戦略ドキュメントでは、公開ベースライン(単体LightGBMチューニング済みでMCC 0.9827程度) を踏まえ、Top1%には「外部データ・モデル多様化・閾値最適化・アンサンブル」が必要と結論づけていました。 実際にやってみて効果が大きかった順にランク付けするとこうなります。

- S-Tier(最重要): UCI Secondary Datasetの結合/MCC特化型閾値のベクトル化最適化/OOFベース・アンサンブル
- A-Tier(高インパクト): AutoGluon(v2)の導入/ノイズの構造化(
__noise__・__missing__への統合) - B-Tier(補助的改善): TabNetの追加/Primary Dataの活用
EDA:評価指標MCCの罠
このコンペの評価指標はMatthews Correlation Coefficient(MCC)。混同行列の4要素すべてを使う相関係数で、 クラス不均衡に頑健な指標です。しかし「確率」ではなく「二値(0/1)」に対して計算されるため、 閾値の選び方でスコアが大きく変わるという性質があります。

0.5に固定してしまうと、Edible/Poisonousの確率分布によっては最適点(この例では0.54)を逃してしまいます。 だから「閾値は必ず最適化する」がS級施策の1つになるわけです。
データランドスケープ:均衡なクラスと、隠されたノイズ
train 3,116,945行、test 2,077,964行。クラス分布はPoisonous 54.7% / Edible 45.3%とほぼバランスしていました。
一方で、カテゴリ列を覗いてみると合成データ特有のノイズが見つかりました。cap-shape列を例に取ると、 UCI由来の正規値7種(x,f,s,b,o,p,c)が全体の99.99%を占め、残りは数十件以下の極端なノイズ値でした。 does-bruise-or-bleed(本来t/fの二値)に”3.43″のような数値文字列が紛れ込んでいるケースまで見つかっています。

→ 対策として、UCIの正規値集合に基づき、それ以外の値をすべて__noise__に統合するクリーニングを 実施しました。
「欠損」という名の構造的シグナル
欠損率を見るとveil-type94.9%、spore-print-color91.4%、stem-root88%など、 極めて高い列が複数ありました。しかもtrainとUCIでほぼ同じ欠損パターンを示していたのが決め手でした。 これは欠損が完全ランダム(MCAR)ではなく、構造的な意味(特定の採取条件など)を持つシグナルだということです。

→ 単純な補完(imputation)はせず、_is_missingフラグ列を独立して追加し、欠損自体を 特徴量化しました。
ゲームチェンジャー:Primary Dataによる「毒性」の数値化
今回いちばんの発見がこれです。コンペにはprimary_data.csvという、173種のキノコの毒性ルール表が 同梱されていました。「対象行が何種の毒キノコ/食用キノコのルールに値レベルで一致するか」を集計し、 primary_match_ratioという特徴量を作ったところ――
target(毒/食用)との相関係数が0.8896という、破壊的な数値が出ました。

primary_poison_match(毒種との一致数)、primary_edible_match (食用種との一致数、逆相関-0.82)も同様に強力で、P(poisonous|value)を列ごとのBayesian特徴量として 実装しました。
次元削減が暴く真の構造:t-SNE vs UMAP
この発見を可視化で裏付けるため、UMAPとt-SNEで2次元投影してみました。ただし全215次元 (One-Hot化した生の特徴量)だと、Edible/Poisonousはまったく分離しませんでした。そこで targetと強く相関するPrimary特徴量21次元だけに絞って再挑戦。

- UMAP:大域構造を保持する性質のため、中心部でEdibleとPoisonousが混在
- t-SNE:局所構造の保持に優れており、明確なクラスタ(島)としてEdibleとPoisonousが分離して出現
同じデータでも次元削減アルゴリズムの性質によって見え方が変わる、という面白い結果になりました。
診断マトリックス:K-means(k=3)が導き出した3つの世界
エルボー法・シルエット法で最適クラスタ数を探索したところ、k=3が最良(シルエットスコア最大)でした。 Kaggle学習データ全311万行に対しPrimary特徴量ベースでK-means(k=3)を実行し、意味を調べてみると――

| クラスタ | 割合 | 中身 |
|---|---|---|
| Cluster 0 | 36.1% | primary_match_ratio ≒ 0.000、Poisonous率0.6% → 確信的食用 |
| Cluster 1 | 44.4% | primary_match_ratio ≒ 0.999、Poisonous率99.6% → 確信的毒 |
| Cluster 2 | 19.5% | primary_match_total = 0.000、Poisonous率52.8% → 未知の領域 |
Cluster 2は「証拠が拮抗する境界事例」ではなく、primary_match_totalがちょうど0であることから分かる通り、 Primary Dataの173種のどれとも一致しない未知の値の組み合わせでした。Poisonous率(52.8%)がtrain全体の 基準率(54.7%)にほぼ一致するのも、Primary Dataが実質何も語っていないことの裏付けです。
ボトルネックの正体:「未知の19.5%」に潜む限界
このCluster 2(全体の19.5%)が、実際に学習済みモデル(LightGBM/XGBoost/AutoGluon/TabNetの4種)に とってどれだけ難しいかを検証しました。

4モデル全てで一貫して、Cluster 2のエラー率が全体平均の約2.7倍。モデル全体の誤りのうち 約半数が、このわずか19.5%のサブセットに集中していました。
誤り行と正解行を比較するとstem-surface欠損など複数の欠損列が関連していたため、複合欠損度を表す critical_missing_count特徴量を追加して再学習しましたが、OOF MCCは0.98466で停滞 (追加前とほぼ同じ)。これは特徴量エンジニアリングの不足ではなく、データセット自体が持つ構造的な限界 (未知の値の組み合わせ)だと結論づけました。うまくいかなかった実験も、ちゃんと記録しておくのが大事だと思っています。
特徴量エンジニアリング・パイプライン
EDAの発見を踏まえ、以下のパイプラインを構築しました。

- カテゴリクリーニング:UCI正規値以外を
__noise__へ統合 - フラグ付与:
_is_missing・_is_noise列の追加 - 数値派生:
stem_ratio、cap_diameter_logなど - 外部データ統合:UCIデータを
is_uciフラグ付きでtrainのみに結合 - 最強のシグナル:Primary Dataに基づくBayesian特徴量の付与
前処理済みデータはParquet形式でキャッシュし、開発効率化のためロード時間を20秒→6秒に短縮しています。
単一モデル・バトル:OOF MCCとハードウェアの相性
LightGBM、XGBoost、CatBoost、AutoGluon、TabNet、そして比較用にロジスティック回帰も学習しました。

- LightGBM/XGBoost v2(Primary特徴量込み):0.98467で最高性能。ただしLightGBMのGPUツリー学習器は 低カーディナリティ特徴量でクラッシュしたため、CPUモードで回避
- CatBoost:0.98415とGBM系で最も低スコア、学習時間も最長(96分)。ブレンド候補から除外
- ロジスティック回帰:One-Hot全特徴量では0.93231とGBM系に惨敗。「全次元では分離不能」という 仮説を裏付ける結果に
アンサンブル戦略:異なるアーキテクチャによる多様性の注入

- GBM系(ベースライン):LightGBM & XGBoost
- AutoGluon v2(AutoML枠):3時間ラン。内部構成はLightGBMXT(65%) + RandomForest Gini(30%) + RandomForest Entropy(5%)
- TabNet(NN枠):決定木とは根本的に異なる学習メカニズムによる多様性のスパイスとして機能
これらをOOF予測に基づく重み付き線形ブレンドで統合しました。
O(n log n)の魔法:MCC閾値のベクトル化最適化
当初は991段階をsklearn.matthews_corrcoefに全探索ループで渡す方式 (計算量 O(n_steps × n))でしたが、ブレンドの重み探索と組み合わせると計算量が膨大に。

予測確率を降順ソートし、累積和(cumsum)でTP/FP/FN/TNを一括計算する方式に置き換えたところ、 離散化誤差のない厳密解を36.3秒で算出できるようになりました。66通りのブレンド探索が数十分→36秒、 イテレーションが劇的に高速化しました。
成長の軌跡:Top 15%到達へのマイルストーン

OOF MCCは0.98473(3モデル)→0.98477(Primary特徴量、ここで最大の傾き)→0.98486(+TabNet)→ 0.98493(+AutoGluon v2)と着実に向上。Kaggle提出のPrivate LBも0.98470→0.98497まで伸びました。
注記:MCCの4〜5桁目の差はサンプリングノイズの範囲内で、Public LBとPrivate LBで順位が逆転する場面も複数回 観測しました。過度なPublic LBへの適合(オーバーフィット)を避け、OOFとモデル多様性を信じた結果が今回のスコアです。
結論と次なる課題

学び(Technical Pitfalls)
- conda runの標準出力バッファリング問題(Python直接呼び出しで回避)
- CatBoost GPU実行時のcallbacks不全(ファイル監視への切り替えで対処)
勝因(Strategic Win)
- 評価指標(MCC)の数学的性質をハックしたベクトル化実装
- 次元削減とK-meansによる「Primary Dataのシグナル」の視覚的証明
次の課題(Next Steps)
- Cluster 2(謎の19.5%)を判別する新たな外部シグナルの探索
- ブレンド重み探索への非線形オプティマイザ(Optuna)の導入
The ultimate battle is always isolating the signal from the noise.
(究極の戦いは、常にノイズからシグナルを分離することにある)