Files
foxhunt/docs/plans/2026-02-26-training-pipeline-implementation.md
jgrusewski 6e339316cf feat(ml): add manually-triggered GitLab CI training pipeline
Adds a parent/child GitLab CI pipeline for ML model training:

- Generator script produces per-model hyperopt/train/evaluate jobs
- Parent pipeline (.gitlab-ci-training.yml) with manual trigger
- NFS-backed ReadWriteMany PVC for shared training outputs
- Hyperopt params wired into training binaries (DQN, PPO, TFT, Mamba2)
- Shared DBN loader eliminates duplicate code across hyperopt adapters
- Supervised hyperopt unified to DBN data (was parquet-only)

Pipeline: hyperopt (4 models) → train (10 models) → evaluate ensemble

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 09:04:58 +01:00

676 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ML Training Pipeline Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Manually-triggered GitLab CI pipeline that runs hyperopt → train → evaluate for the 10-model ML ensemble on H100 GPUs.
**Architecture:** A parent `.gitlab-ci-training.yml` accepts trigger variables, runs a shell script that generates a child pipeline YAML with exactly the needed jobs (models × symbols × phases), then triggers the child pipeline. Jobs run in the pre-built training Docker image with PVC mounts for data and output.
**Tech Stack:** GitLab CI (parent/child pipelines), Bash (YAML generator), Rust/Clap (--params-file arg), Kubernetes PVCs
---
### Task 1: Output PVC manifest
**Files:**
- Create: `infra/k8s/training/training-output-pvc.yaml`
**Step 1: Create the PVC manifest**
```yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: training-output-pvc
namespace: foxhunt
labels:
app.kubernetes.io/name: training-output
app.kubernetes.io/part-of: foxhunt
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 50Gi
storageClassName: scw-bssd
```
50Gi because safetensors checkpoints for 10 models × 4 symbols × multiple folds can add up. scw-bssd matches the existing training-data-pvc.
**Step 2: Commit**
```bash
git add infra/k8s/training/training-output-pvc.yaml
git commit -m "infra: add training-output-pvc for ML pipeline checkpoints"
```
---
### Task 2: Pipeline generator script
**Files:**
- Create: `scripts/generate-training-pipeline.sh`
This script reads environment variables and writes a `.training-generated.yml` artifact with the exact jobs needed.
**Step 1: Write the generator script**
The script must:
1. Parse `MODELS` into a list (expand `all` → 10 models, `rl` → dqn,ppo, `supervised` → 8 models)
2. Parse `SYMBOLS` into a list (comma-separated)
3. Map each model to its binary (`dqn`/`ppo``*_rl`, others → `*_supervised`)
4. Map each model to whether it has hyperopt support (`dqn`,`ppo`,`tft`,`mamba2` → yes, others → no)
5. Write stages, variables, and job definitions to YAML
6. Respect `PHASE` variable to skip hyperopt or train stages
Key details:
- All jobs use the training image from `$REGISTRY/training:latest`
- All jobs mount `training-data-pvc` at `/data` (read-only) and `training-output-pvc` at `/output`
- All jobs use `nodeSelector: gpu-training` and request 1 GPU
- Hyperopt jobs for RL use `--data-dir /data` (DBN files with symbol subdirs)
- Hyperopt jobs for supervised use `--parquet-file` — but the supervised hyperopt binary expects Parquet, NOT DBN. This is a design mismatch. For now, the generator skips supervised hyperopt unless a `PARQUET_DIR` variable is set. This is a known limitation documented in the script.
- Train jobs pass `--params-file /output/$RUN_ID/hyperopt/$MODEL/$SYMBOL/results.json` if hyperopt ran
- The evaluate job `needs:` all train jobs
The script location: `scripts/generate-training-pipeline.sh`
```bash
#!/usr/bin/env bash
set -euo pipefail
# --- Input variables (from GitLab CI trigger) ---
SYMBOLS="${SYMBOLS:-ES.FUT}"
MODELS="${MODELS:-all}"
PHASE="${PHASE:-full}"
MAX_PARALLEL="${MAX_PARALLEL:-10}"
EPOCHS="${EPOCHS:-50}"
HYPEROPT_TRIALS="${HYPEROPT_TRIALS:-20}"
RUN_ID="${RUN_ID:-$(date +%Y%m%d-%H%M%S)}"
REGISTRY="${REGISTRY:-rg.fr-par.scw.cloud/foxhunt-ci}"
OUTPUT=".training-generated.yml"
# --- Expand model groups ---
RL_MODELS="dqn ppo"
SUPERVISED_MODELS="tft mamba2 tggn tlob liquid kan xlstm diffusion"
HYPEROPT_MODELS="dqn ppo tft mamba2"
case "$MODELS" in
all) MODEL_LIST="$RL_MODELS $SUPERVISED_MODELS" ;;
rl) MODEL_LIST="$RL_MODELS" ;;
supervised) MODEL_LIST="$SUPERVISED_MODELS" ;;
*) MODEL_LIST=$(echo "$MODELS" | tr ',' ' ') ;;
esac
IFS=',' read -ra SYMBOL_LIST <<< "$SYMBOLS"
# --- Helper: which binary for a model ---
binary_for() {
case "$1" in
dqn|ppo) echo "train_baseline_rl" ;;
*) echo "train_baseline_supervised" ;;
esac
}
hyperopt_binary_for() {
case "$1" in
dqn|ppo) echo "hyperopt_baseline_rl" ;;
tft|mamba2) echo "hyperopt_baseline_supervised" ;;
*) echo "" ;;
esac
}
has_hyperopt() {
for m in $HYPEROPT_MODELS; do
[ "$m" = "$1" ] && return 0
done
return 1
}
is_rl() {
[ "$1" = "dqn" ] || [ "$1" = "ppo" ]
}
# --- Write YAML header ---
cat > "$OUTPUT" <<YAML
# Auto-generated training pipeline — do not edit
# Run ID: $RUN_ID
# Models: $MODEL_LIST
# Symbols: ${SYMBOL_LIST[*]}
# Phase: $PHASE
stages:
- hyperopt
- train
- evaluate
variables:
RUN_ID: "$RUN_ID"
.training-base:
image: ${REGISTRY}/training:latest
tags:
- kapsule
- rust
variables:
NVIDIA_VISIBLE_DEVICES: all
NVIDIA_DRIVER_CAPABILITIES: compute,utility
RUST_LOG: info
SQLX_OFFLINE: "true"
before_script:
- nvidia-smi || true
- mkdir -p /output/\$RUN_ID
YAML
# --- Generate hyperopt jobs ---
HYPEROPT_JOBS=""
if [ "$PHASE" = "full" ] || [ "$PHASE" = "hyperopt" ]; then
for model in $MODEL_LIST; do
has_hyperopt "$model" || continue
for symbol in "${SYMBOL_LIST[@]}"; do
job_name="hyperopt-${model}-${symbol//\./-}"
HYPEROPT_JOBS="$HYPEROPT_JOBS $job_name"
cat >> "$OUTPUT" <<YAML
${job_name}:
extends: .training-base
stage: hyperopt
resource_group: gpu-${job_name}
script:
YAML
if is_rl "$model"; then
cat >> "$OUTPUT" <<YAML
- mkdir -p /output/\$RUN_ID/hyperopt/${model}/${symbol}
- hyperopt_baseline_rl
--model ${model}
--trials ${HYPEROPT_TRIALS}
--epochs 10
--data-dir /data
--symbol ${symbol}
--output /output/\$RUN_ID/hyperopt/${model}/${symbol}/results.json
--base-dir /tmp/ml_training
YAML
else
# Supervised hyperopt needs parquet — skip if not available
cat >> "$OUTPUT" <<YAML
- echo "Supervised hyperopt for ${model} requires --parquet-file"
- echo "Skipping — train will use default config"
- mkdir -p /output/\$RUN_ID/hyperopt/${model}/${symbol}
YAML
fi
done
done
fi
# --- Generate train jobs ---
TRAIN_JOBS=""
if [ "$PHASE" = "full" ] || [ "$PHASE" = "train" ]; then
for model in $MODEL_LIST; do
for symbol in "${SYMBOL_LIST[@]}"; do
job_name="train-${model}-${symbol//\./-}"
TRAIN_JOBS="$TRAIN_JOBS $job_name"
# Build needs list (depend on hyperopt if it ran)
hyperopt_job="hyperopt-${model}-${symbol//\./-}"
if [ "$PHASE" = "full" ] && has_hyperopt "$model"; then
needs="[${hyperopt_job}]"
else
needs="[]"
fi
# Build params-file arg if hyperopt might have run
params_arg=""
if has_hyperopt "$model"; then
params_arg="--hyperopt-params /output/\$RUN_ID/hyperopt/${model}/${symbol}/results.json"
fi
cat >> "$OUTPUT" <<YAML
${job_name}:
extends: .training-base
stage: train
needs: ${needs}
resource_group: gpu-${job_name}
script:
- mkdir -p /output/\$RUN_ID/models/${model}/${symbol}
YAML
if is_rl "$model"; then
cat >> "$OUTPUT" <<YAML
- train_baseline_rl
--model ${model}
--epochs ${EPOCHS}
--data-dir /data
--output-dir /output/\$RUN_ID/models/${model}/${symbol}
${params_arg}
YAML
else
cat >> "$OUTPUT" <<YAML
- train_baseline_supervised
--model ${model}
--epochs ${EPOCHS}
--data-dir /data
--symbol ${symbol}
--output-dir /output/\$RUN_ID/models/${model}/${symbol}
YAML
fi
done
done
fi
# --- Generate evaluate job ---
if [ "$PHASE" = "full" ] || [ "$PHASE" = "eval" ]; then
# Build needs list from all train jobs
if [ -n "$TRAIN_JOBS" ]; then
needs_list=$(echo "$TRAIN_JOBS" | xargs -n1 | sed 's/.*/"&"/' | paste -sd,)
needs="[${needs_list}]"
else
needs="[]"
fi
cat >> "$OUTPUT" <<YAML
evaluate-ensemble:
extends: .training-base
stage: evaluate
needs: ${needs}
script:
- mkdir -p /output/\$RUN_ID/eval
- evaluate_baseline
--models-dir /output/\$RUN_ID/models
--data-dir /data
--output /output/\$RUN_ID/eval/ensemble_report.json
- cat /output/\$RUN_ID/eval/ensemble_report.json
artifacts:
paths:
- /output/\$RUN_ID/eval/ensemble_report.json
when: always
expire_in: 30 days
YAML
fi
echo "Generated pipeline: $OUTPUT"
echo " Models: $MODEL_LIST"
echo " Symbols: ${SYMBOL_LIST[*]}"
echo " Phase: $PHASE"
echo " Run ID: $RUN_ID"
```
Make it executable.
**Step 2: Verify the script generates valid YAML**
Run locally with defaults:
```bash
chmod +x scripts/generate-training-pipeline.sh
REGISTRY=rg.fr-par.scw.cloud/foxhunt-ci scripts/generate-training-pipeline.sh
cat .training-generated.yml
```
Expected: YAML with hyperopt jobs for dqn, ppo (tft/mamba2 skipped — no parquet), 10 train jobs for ES.FUT, and 1 evaluate job.
**Step 3: Test with subset**
```bash
MODELS=dqn SYMBOLS=ES.FUT PHASE=train scripts/generate-training-pipeline.sh
cat .training-generated.yml
```
Expected: YAML with only `train-dqn-ES-FUT` and `evaluate-ensemble`. No hyperopt jobs.
**Step 4: Commit**
```bash
git add scripts/generate-training-pipeline.sh
echo ".training-generated.yml" >> .gitignore
git add .gitignore
git commit -m "feat: add training pipeline generator script"
```
---
### Task 3: Parent pipeline YAML
**Files:**
- Create: `.gitlab-ci-training.yml`
**Step 1: Write the parent pipeline**
```yaml
# ML Training Pipeline — manually triggered
#
# Trigger via GitLab UI: CI/CD → Pipelines → Run pipeline
# Select .gitlab-ci-training.yml as the CI config, set variables.
#
# Or via API:
# curl -X POST --fail \
# -F "token=$TRIGGER_TOKEN" \
# -F "ref=main" \
# -F "variables[MODELS]=all" \
# -F "variables[SYMBOLS]=ES.FUT" \
# -F "variables[PHASE]=full" \
# "$CI_API_V4_URL/projects/$CI_PROJECT_ID/trigger/pipeline"
stages:
- prepare
- trigger
variables:
SYMBOLS: "ES.FUT"
MODELS: "all"
PHASE: "full"
MAX_PARALLEL: "10"
EPOCHS: "50"
HYPEROPT_TRIALS: "20"
RUN_ID: ""
REGISTRY: rg.fr-par.scw.cloud/foxhunt-ci
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "web"
- if: $CI_PIPELINE_SOURCE == "trigger"
- if: $CI_PIPELINE_SOURCE == "api"
generate-jobs:
stage: prepare
image: alpine:3.19
tags:
- kapsule
script:
- apk add --no-cache bash coreutils
- |
if [ -z "$RUN_ID" ]; then
export RUN_ID=$(date +%Y%m%d-%H%M%S)
fi
- bash scripts/generate-training-pipeline.sh
- cat .training-generated.yml
artifacts:
paths:
- .training-generated.yml
expire_in: 1 day
run-training:
stage: trigger
trigger:
include:
- artifact: .training-generated.yml
job: generate-jobs
strategy: depend
```
Note: The child pipeline jobs reference the training image and GPU runner tags. The parent runs on a lightweight alpine image (no GPU needed).
**Step 2: Verify YAML syntax**
```bash
python3 -c "import yaml; yaml.safe_load(open('.gitlab-ci-training.yml'))" && echo "Valid YAML"
```
**Step 3: Commit**
```bash
git add .gitlab-ci-training.yml
git commit -m "feat: add manually-triggered ML training pipeline"
```
---
### Task 4: Wire --hyperopt-params in train_baseline_rl
**Files:**
- Modify: `crates/ml/examples/train_baseline_rl.rs`
The `--hyperopt_params` arg already exists (line 71-73) but is unused. Wire it up to override DQN/PPO config fields from the hyperopt JSON.
**Step 1: Read the hyperopt output format**
The hyperopt JSON looks like:
```json
{
"dqn": {
"best_objective": 0.123,
"best_params": { "learning_rate": 0.001, "hidden_dim": 128, ... },
"trials": 20,
"elapsed_secs": 45.3
}
}
```
Or for single-model runs:
```json
{
"best_objective": 0.123,
"best_params": { "learning_rate": 0.001, ... },
"trials": 20,
"elapsed_secs": 45.3
}
```
**Step 2: Add a helper function to load and apply hyperopt params**
Add after the `Args` struct:
```rust
/// Load hyperopt results JSON and extract best_params for the given model.
/// Returns None if file doesn't exist or model key is missing.
fn load_hyperopt_params(path: &Path, model: &str) -> Option<serde_json::Value> {
let content = std::fs::read_to_string(path).ok()?;
let json: serde_json::Value = serde_json::from_str(&content).ok()?;
// Try model-keyed format first: {"dqn": {"best_params": {...}}}
if let Some(params) = json.get(model).and_then(|m| m.get("best_params")) {
return Some(params.clone());
}
// Try flat format: {"best_params": {...}}
if let Some(params) = json.get("best_params") {
return Some(params.clone());
}
None
}
```
**Step 3: Apply params to DQNConfig and PPOConfig**
In the DQN training section, after creating the default config, add:
```rust
if let Some(ref params_path) = args.hyperopt_params {
if let Some(params) = load_hyperopt_params(params_path, "dqn") {
info!("Loading hyperopt params from: {}", params_path.display());
if let Some(lr) = params.get("learning_rate").and_then(|v| v.as_f64()) {
dqn_config.learning_rate = lr;
info!(" learning_rate = {}", lr);
}
if let Some(hd) = params.get("hidden_dim").and_then(|v| v.as_u64()) {
dqn_config.hidden_dim = hd as usize;
info!(" hidden_dim = {}", hd);
}
// Add additional param mappings as hyperopt search space grows
} else {
info!("No hyperopt params found at {}; using defaults", params_path.display());
}
}
```
Same pattern for PPO section, reading "ppo" key and mapping to PPOConfig fields.
**Step 4: Build and verify**
```bash
SQLX_OFFLINE=true cargo check -p ml --example train_baseline_rl
```
Expected: compiles with 0 errors, 0 warnings.
**Step 5: Commit**
```bash
git add crates/ml/examples/train_baseline_rl.rs
git commit -m "feat(ml): wire --hyperopt-params to override DQN/PPO config"
```
---
### Task 5: Add --hyperopt-params to train_baseline_supervised
**Files:**
- Modify: `crates/ml/examples/train_baseline_supervised.rs`
**Step 1: Add the CLI arg**
Add to the `Args` struct after `spread_ticks`:
```rust
/// Optional path to hyperopt results JSON to override default config
#[arg(long)]
hyperopt_params: Option<PathBuf>,
```
**Step 2: Add the same `load_hyperopt_params` helper**
Same function as Task 4.
**Step 3: Apply params to model configs**
In each model's config construction (TFT, Mamba2, etc.), add the override block. Only TFT and Mamba2 have hyperopt adapters, so only those need param mapping. Example for TFT:
```rust
if let Some(ref params_path) = args.hyperopt_params {
if let Some(params) = load_hyperopt_params(params_path, "tft") {
info!("Loading TFT hyperopt params from: {}", params_path.display());
if let Some(lr) = params.get("learning_rate").and_then(|v| v.as_f64()) {
tft_config.learning_rate = lr as f32;
info!(" learning_rate = {}", lr);
}
if let Some(hd) = params.get("hidden_size").and_then(|v| v.as_u64()) {
tft_config.hidden_size = hd as usize;
info!(" hidden_size = {}", hd);
}
}
}
```
For models without hyperopt (TGGN, TLOB, etc.), no changes — the param file simply won't have their key.
**Step 4: Build and verify**
```bash
SQLX_OFFLINE=true cargo check -p ml --example train_baseline_supervised
```
**Step 5: Commit**
```bash
git add crates/ml/examples/train_baseline_supervised.rs
git commit -m "feat(ml): add --hyperopt-params to supervised training binary"
```
---
### Task 6: Update Dockerfile.training for PVC mounts
**Files:**
- Modify: `infra/k8s/training/job-template.yaml`
**Step 1: Update the job template**
The current template has `training-data-pvc` at `/data` and `emptyDir` at `/output`. Replace the output volume with the new PVC and add nodeSelector for H100.
Update `volumes` section:
```yaml
volumes:
- name: training-data
persistentVolumeClaim:
claimName: training-data-pvc
- name: output
persistentVolumeClaim:
claimName: training-output-pvc
```
The nodeSelector is already `gpu-training` which is the H100 pool. No change needed there.
**Step 2: Verify YAML**
```bash
python3 -c "import yaml; yaml.safe_load(open('infra/k8s/training/job-template.yaml'))" && echo "Valid"
```
**Step 3: Commit**
```bash
git add infra/k8s/training/job-template.yaml
git commit -m "infra: use training-output-pvc instead of emptyDir in job template"
```
---
### Task 7: Integration test — dry run the full pipeline locally
**Step 1: Generate the pipeline for full ensemble**
```bash
MODELS=all SYMBOLS=ES.FUT PHASE=full scripts/generate-training-pipeline.sh
```
**Step 2: Verify job count**
Expected in `.training-generated.yml`:
- 2 hyperopt jobs (dqn, ppo — tft/mamba2 skipped without parquet)
- 10 train jobs (all models)
- 1 evaluate job
- Total: 13 jobs
```bash
grep -c "^[a-z].*:$" .training-generated.yml
```
**Step 3: Verify multi-symbol expansion**
```bash
MODELS=dqn,ppo SYMBOLS=ES.FUT,NQ.FUT PHASE=full scripts/generate-training-pipeline.sh
```
Expected:
- 4 hyperopt jobs (dqn×2 symbols, ppo×2 symbols)
- 4 train jobs
- 1 evaluate job
- Total: 9 jobs
**Step 4: Verify phase filtering**
```bash
MODELS=kan SYMBOLS=ES.FUT PHASE=train scripts/generate-training-pipeline.sh
```
Expected: 0 hyperopt, 1 train, 1 evaluate. Total: 2 jobs.
**Step 5: Final commit with all files**
```bash
git add -A
git status
git commit -m "feat: ML training pipeline — generator, CI config, PVC, param passing"
```
---
### Task 8: Update design doc with known limitations
**Files:**
- Modify: `docs/plans/2026-02-26-training-pipeline-design.md`
Add a "Known Limitations" section:
1. Supervised hyperopt (`hyperopt_baseline_supervised`) expects Parquet input, not DBN. The generator currently skips supervised hyperopt. Future work: add DBN support to supervised hyperopt adapters.
2. `evaluate_baseline` currently only evaluates RL models (DQN/PPO). Extending it to load and evaluate all 10 model types needs work on the evaluation binary.
3. The output PVC is `ReadWriteOnce` — only one node can mount it at a time. If parallel jobs land on different nodes, they'll fail. Mitigation: use `ReadWriteMany` with an NFS-backed storage class, or ensure all training pods schedule on the same node.
**Commit**
```bash
git add docs/plans/2026-02-26-training-pipeline-design.md
git commit -m "docs: add known limitations to training pipeline design"
```