「直りました」と言ってから3日後。まったく同じエラーで、学習バッチが全件失敗していました😱 最初の修正は、そもそも効いていなかったのです。
日本株のAI銘柄スクリーニング&継続学習端末「ALPHA FORGE(アルファフォージ)」を、Claude Code と二人三脚で個人開発しています。今回は【事件簿】回。銘柄ごとの機械学習を並列化したら、Windows 環境で子プロセスが次々に即死したトラブルの記録です。Python のマルチプロセスを Windows で運用している方なら、きっと身に覚えがあるかもしれません🙈
【要約:この記事で伝えたいこと】
- 銘柄ごとの機械学習を並列化したら、Windows の spawn 方式のせいでテストの monkeypatch 隔離が子に届かず、本番では子が
ModuleNotFoundError: No module named 'backend'で即死した💀 - 最初の対策(環境変数
PYTHONPATH)は、子プロセスの起動直後の初期化処理で上書きされて消えており、3日後に全件失敗が再発した - 持ち帰り:spawn の子は「親プロセスの
sys.pathのコピー」で初期化される。だから上書きされる側ではなく、コピー元である親のsys.pathを直す
※本記事はnote連載[ALPHA FORGE 開発秘話 #15] の技術的な部分を、エンジニア向けに再構成したものです。
⚙️ 銘柄ごとの学習を並列化したかった
ALPHA FORGE は、銘柄ごとに小さな機械学習モデル(XGBoost / LightGBM / RandomForest 等)を持っており、毎晩少しずつ直近データで学び直しています。対象は数千銘柄。1銘柄ずつ順番に学習していては、とても夜のうちに終わりません。
そこで9月20日、学習処理を並列化することにしました💪
設計方針は事前に Claude Code としっかり議論して決めています。
| 観点 | 設計方針 | 理由 |
|---|---|---|
| Celery ワーカー | 1プロセス(直列)のまま維持 | Windows での Celery マルチプロセスワーカーの不安定さを回避するため |
| バッチ内部の並列化 |
ProcessPoolExecutor を使用 |
GIL を回避し、CPU バウンドなモデル学習を複数コアへ分散するため |
| 子プロセスの責務 | 純粋な計算とファイル保存のみ | プロセス間での DB 接続競合や重いコネクション共有を防ぐため |
子プロセス側で実行する関数は、DB アクセスやネットワーク I/O を一切行わず、引数で渡された DataFrame からモデルを学習して保存するだけの「純粋な関数」として切り出しました。
# backend/services/learning/training_worker.py(抜粋)
@dataclass(frozen=True)
class TrainWorkerResult:
"""子プロセスから親プロセスへ返す学習結果(プリミティブ型のみ、pickle 安全)."""
ticker: str
metrics: dict[str, float | str | int | bool]
artifact_path: str
comparison_rmse: float | None
def train_ticker_in_subprocess(
*,
ticker: str,
model_type: str,
version: str,
df: pd.DataFrame,
forecast_horizon: int,
existing_artifact_path: str | None,
) -> TrainWorkerResult:
"""1銘柄を学習・保存する同期関数(子プロセス用)."""
predictor = get_predictor(model_type)
metrics = predictor.train(df, {}, forecast_horizon=forecast_horizon)
artifact_path = predictor.save(version)
# (既存モデルとの比較検証など)
return TrainWorkerResult(...)
ここまでは、我ながら美しく堅牢なアーキテクチャでした✨ …このあと盛大にハマるとも知らずに。
💥 Windows の spawn は「まっさらな Python」で子を起こす

Linux は親の複製、Windows はまっさらな Python。引き継がれるものが違う
Linux 環境における Python のマルチプロセスは、親プロセスをメモリごと複製する fork 方式がデフォルトでした。しかし、Windows には fork が存在しません。Windows では必ず、新しい Python インタプリタを一から起動してスクリプトを再インポートする spawn 方式が使われます。
親プロセスがメモリ上で動的に変更した状態は、子プロセスには一切引き継がれません。この違いが、2回、まったく別の形で牙をむきました。
1回目:テストの monkeypatch 隔離が子に届かない
pytest のテストコードでは、本番用のモデル保存先ディレクトリを汚さないよう、一時ディレクトリへパスを差し替える monkeypatch を適用していました。
ところが、spawn で起動した子プロセスは、まっさらな状態でモジュールを新規インポートします。そのため、親プロセスで monkeypatch された設定を無視して、本番の保存先ディレクトリにモデルを書き込もうとしてしまったのです😨
この問題に対しては、AI(Claude Code)が即座に気づき、プロセスプールを生成するファクトリ関数を切り出しました。
# backend/services/learning/per_ticker_training_service.py(抜粋)
def _create_worker_pool(max_workers: int) -> ProcessPoolExecutor | None:
"""学習バッチ用のプロセスプールを生成する(テストから差し替え可能にするための間接層)."""
return ProcessPoolExecutor(max_workers=max_workers)
テスト環境(conftest.py の autouse fixture)では、この関数が None を返すように差し替えます。loop.run_in_executor(None, ...) に None を渡すと同一プロセス内の既定スレッドプールが使われるため、子プロセスが生成されず、テストの monkeypatch 隔離が守られるようになりました。
2回目:本番で子が ModuleNotFoundError で即死する
テストが緑になった同日の午後、本番の Celery ワーカーで学習バッチを走らせたところ、全銘柄が失敗しました。子プロセスが吐いたエラーログはこれです。
ModuleNotFoundError: No module named 'backend'
不思議なことに、同じバッチ処理を FastAPI(Webサーバー)経由で実行したときには一切再現しませんでした。
AIが実行経路の違いを丹念に突き合わせた結果、「Celery の CLI コマンド(celery.exe)から起動されたワーカープロセスだけで発生する」と判明しました。そこで、「子プロセスにプロジェクトルートが見えるようにしよう」と、環境変数 PYTHONPATH にプロジェクトルートの絶対パスを設定する対策を入れました。
これでエラーは消え、バッチも動き出しました。「直った!」――そう信じ込んでいたのです。

テストでは隔離が届かず、本番では子が即死。手がかりは「起動方法」だった
Windows × multiprocessing のハマりどころ
- Windows の
spawnは新しい Python インタプリタを起動するため、モジュールが再インポートされる - クロージャやローカル関数、インスタンスメソッドは pickle できない(トップレベル関数必須)
- 親プロセスのモジュール内グローバル変数や monkeypatch は引き継がれない
- テストをスレッドプールに逃がすと、子プロセス固有の起動・インポートエラーを検知できなくなる
🔁 3日後、全件失敗が再発した

「直った」と思った3日後に再発。最初の対策は効いていなかった
事件から3日後の9月23日、夜間の学習バッチが再び全件失敗しました。ログを確認すると、またしてもあの忌まわしいエラーです。
ModuleNotFoundError: No module named 'backend'
「えっ、環境変数 PYTHONPATH で直したはずでは…?😇」
今度は AI と一緒に、Python の multiprocessing および Celery の内部コードまでステップ実行レベルで追いかけました。そこで明らかになった衝撃の事実は次の2点でした。
-
Celery の挙動:
celery.exe -A backend.celery_appは、アプリケーションモジュールをインポートする瞬間だけcwd_in_path()でカレントディレクトリをsys.pathに一時追加し、インポートが終わるとsys.pathからキレイに削除してしまう。そのため、親プロセスのメモリ上にはbackendがロード済みであるにもかかわらず、親のsys.pathにはプロジェクトルートが残っていない。 -
multiprocessing.spawnの初期化処理:spawnで起動した子プロセスは、初期化ルーチン(multiprocessing.spawn.prepare)の途中で、子プロセスのsys.pathを「親プロセスのsys.pathのコピー」で丸ごと上書きする。
つまり、環境変数 PYTHONPATH によって子プロセス起動の最初の一瞬だけ sys.path に追加されていたパスは、親から受け取った sys.path のコピーによって上書きされ、綺麗サッパリ消し去られていたのです!
最初の修正は、たまたまその時の実行コンテキスト(カレントディレクトリが残っていた等)で通っていただけで、根本的な解決になっていませんでした。
🔧 親の sys.path を直せば、子にもコピーされる

上書きされる側ではなく、コピー元の親の sys.path を直す
子プロセスが起動直後に「親の sys.path」で自身を上書きするのなら、話はシンプルです。上書きされる側(子)をどうにかしようとするのではなく、コピー元である親プロセスの sys.path を直せばよいのです。
最終的な修正は、たった3行でした✨
# backend/services/learning/per_ticker_training_service.py(抜粋)
_PROJECT_ROOT: Final[Path] = Path(__file__).resolve().parents[3]
def _ensure_project_root_on_sys_path() -> None:
"""ProcessPoolExecutor が spawn する子プロセスへ backend パッケージを確実に見せる."""
root = str(_PROJECT_ROOT)
if root not in sys.path:
sys.path.insert(0, root)
def _create_worker_pool(max_workers: int) -> ProcessPoolExecutor | None:
_ensure_project_root_on_sys_path()
return ProcessPoolExecutor(max_workers=max_workers)
プロセスプールを生成する直前に、親プロセスの sys.path の先頭にプロジェクトルートを差し込みます。これにより、spawn で起動した子プロセスが親の sys.path をコピーした際、確実にプロジェクトルートが含まれるようになります。
コードの docstring には、当時の生々しい経緯をそのまま残しました。
def _ensure_project_root_on_sys_path() -> None:
"""`ProcessPoolExecutor` が spawn する子プロセスへ `backend` パッケージを確実に見せる.
`celery.exe -A backend.celery_app` は app の import 中だけ `cwd_in_path()` で cwd を
`sys.path` に足し、import 後に取り除く。そのため親プロセスは `backend` を import 済みでも
`sys.path` にプロジェクトルートが残らない。spawn の子プロセスは起動直後に `sys.path` を
親のコピーで丸ごと上書きする(`multiprocessing.spawn.prepare`)ので、子は
`ModuleNotFoundError: No module named 'backend'` で即死し、プールが壊れて後続全銘柄が
失敗する(2026-09-20 発生)。当初は `PYTHONPATH` 環境変数で対策したが、この上書きで
消されて効いておらず 2026-09-23 に全件失敗が再発した。親の `sys.path` 自体へ入れれば
子へコピーされる。
"""
なぜこの3行が必要なのか、過去にどんな失敗をして再発したのかをコード上に明記しておくことで、将来の自分や別の AI が「不要なコード」と見なして削除したり、効かない PYTHONPATH 方式へ逆戻りするのを防ぐことができます🛡️
もう1つの潜んでいた罠:学習枠の食い潰し
実はこの時、バッチの進捗が止まっていた理由がもう1つありました👀
ヒストリカルデータが極端に不足していて毎回学習に失敗する約200銘柄が存在したのですが、キューの取得順が「未学習(学習成功レコードなし)」を優先するロジックになっていたため、毎晩この200銘柄が先頭に並び、1日の学習リミット枠を食い潰していたのです。
その結果、正常に学習可能な約4,230銘柄の定期更新が完全にストップしていました。こちらは翌日、取得順を「最後に試行した日時(古い順)」へ修正することで解消しました。
🧰 ハーネス術:本番と同じ起動方法で確かめる
今回の失敗の本質は、「再現した起動方法そのもの」で検証しきれていなかった点にあります。
| 起動経路 | ワーカー実行方式 | spawn 発生 | 結果 |
|---|---|---|---|
| pytest 単体テスト | スレッドプール(隔離) | ❌ なし | エラーが出ない(検知不可) |
| FastAPI Web サーバー | ProcessPoolExecutor |
⭕ あり(正常) |
sys.path にカレントディレクトリが残っており成功する |
| Celery CLI コマンド | ProcessPoolExecutor |
⭕ あり(異常) | Celery が sys.path を除去するため即死する |
テストや Web 経由の簡易確認で「動いたからヨシ!」としてしまうと、プロセスツリーや環境変数の継承経路の違いによって本番だけで死ぬバグを見逃します。
現在では、この教訓をもとに以下の検証フローを徹底しています👇
- どの起動方法で起きるかを先に特定する: 起きる経路と起きない経路を並べて環境の差異(カレントディレクトリ、sys.path、環境変数)を洗う
- 直したら、その起動方法そのもので再現テストをする: コマンドライン、サービスマネージャ等、本番と寸分違わぬエントリーポイントで叩く
- 効かなかった対策は、消さずにコメントへ経緯を残す: なぜそれがダメだったかを残すことが、再発防止の最高のドキュメントになる
🤝 まとめ:今回の役割分担
- 気づいたのは人間👀: XGBoost / RandomForest の学習進捗が95%付近でピタッと止まっている異常値に気づき、「一度きれいに全件を初期化して学習し直す」と決断した
-
突き止めたのはAI🔍: Windows spawn と monkeypatch の相性問題、起動経路ごとの挙動差、そして
multiprocessing.spawn.prepareがsys.pathを上書きする仕様を調査・特定した - 決めたのは人間🧭: 並列化のアーキテクチャ方針(ワーカーは直列、内部だけプロセスプール)と、安全な並列数リミットの設計を採用した
AIエージェントは非常に優秀ですが、検証環境が本番とわずかにズレていると「直りました!」と自信満々に報告してきます。その報告を鵜呑みにせず、本番と全く同じ条件で再現確認を求めるのが、人間の重要な役割です🤝
次回は【事件簿】回。「600秒無応答 → 103秒 ― 捨てるための計算を止めた話」。結果を捨てるだけの計算のせいで、朝の銘柄スクリーニングが応答しなくなっていたトラブルを振り返ります🗑️
☕ note.comで開発秘話と毎朝の検証ログを連載中
note.comでは、ALPHA FORGE の開発秘話と、毎朝のアルゴリズム検証ログを公開しています。
👉 元記事(note連載第15話):
【ALPHA FORGE 開発秘話 #15】「直りました」の3日後、同じエラーで全件失敗💀 ― Windows × spawn の罠
免責事項
本記事は個人開発およびAIエージェントを活用したシステム開発の技術記録です。特定の銘柄の売買推奨や投資助言を行うものではありません。本ツールは証券会社等への発注機能を持ちません。記事中の日付・発言は開発時の記録にもとづくもので、AIの返答は要約です。紹介したコードやルールは要旨であり、実際の設定ファイルそのものではありません。
⚠️ 【システム検証記録に関する免責事項・注意事項】
- 本記事は、独自開発アルゴリズム「ALPHA FORGE」の動作検証およびデータ分析過程を公開する技術・運用の個人的な記録ログです。
- 掲載されているすべてのデータ(各スコア、基準観測値、統計的変動上限、シナリオ無効化水準など)は、過去の市場データに基づき数式(ATR等)により機械的に算出されたバックテスト・シミュレーション用のパラメータであり、特定の有価証券の売買勧誘、取引の推奨、投資助言・代理行為を目的としたものではありません。
- また、将来の株価変動や運用成果を保証するものではありません。実際の投資判断および最終決定は、必ずご自身の責任と判断において行っていただけますようお願いいたします。

