feat(aux-labels): replace D with A+B paired labels + loader/trainer migration (CB1+CB2)
Smoke 1 v2 empirically falsified the D-style labels: 99.99% of K=100 D-labels
were negative on real ES MBP-10 (cost+1.5×MaxDD dominates every typical
100-tick move). Aux head couldn't escape predicting the mean.
CB1 — replace label generator:
- generate_outcome_labels_d → generate_outcome_labels_ab returning
OutcomeLabelsAB { y_prof_{long,short}, y_size_{long,short}, sigma_k,
cost_price_units, pos_fraction }
- y_prof = 1 iff signed_pnl > 2×cost (binary, class-weighted BCE target)
- y_size = signed_pnl / σ_K (σ-normalized regression target,
conditional-masked when y_prof=0)
- σ_K: rolling 1000-bar Welford std of K-step log-returns, floored at
cost/4 per pearl_trade_level_vol_for_stop_distance
- pos_fraction: per-(direction, horizon) positive class fraction for
downstream BCE pos_weight balancing
- 10 unit tests validating sign-correctness, NaN edges, balance,
cost-threshold, sigma-floor, per-horizon independence, error paths
CB2 — atomic caller migration:
- LabeledSequence + LoadedFile: outcome_long/short (2 arrays) → 5 arrays
(prof_long, prof_short, size_long, size_short, sigma_k) + pos_fraction
- Loader splits new generator's outputs + propagates pos_fraction
file-level into each yielded LabeledSequence
- step / step_batched signatures widened to 7 params + pos_fraction
- alpha_train.rs: 4 separate batches + per-snapshot row build for each
- last_pos_fraction stashed on PerceptionTrainer
- aux test fixture (synthetic_aux_outcomes) updated to emit 4 arrays +
pos_fraction matching the constant-up-ramp test signal
- HOLDING PATTERN: step_batched body validates new arrays + stages
prof-binary through the existing D-era Huber kernel so training
produces a real gradient. CB3 rewrites the kernel; CB4 widens head
outputs; CB5 wires BCE+Huber proper loss with class weighting.
cargo check --workspace --all-targets: clean (only pre-existing cudarc
cupti + ml insert_batch unrelated errors).
cargo test -p ml-alpha --lib: 43 passed (15 multi_horizon_labels).
cargo test -p ml-backtesting --lib: 33 passed.
stacked_trainer_aux_supervision RTX 3050: pass (huber=0.0004, dir_acc=1.0,
stop_grad lifted).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -484,12 +484,26 @@ fn main() -> Result<()> {
|
||||
// don't pollute the batch.
|
||||
let mut snap_batch: Vec<Vec<Mbp10RawInput>> = Vec::with_capacity(cli.batch_size);
|
||||
let mut label_batch: Vec<Vec<[f32; N_HORIZONS]>> = Vec::with_capacity(cli.batch_size);
|
||||
// SDD-3 Layer B5: aux outcome labels from the loader's D-style
|
||||
// generator. NaN slots are natively masked by the Huber loss kernel.
|
||||
let mut out_long_batch: Vec<Vec<[f32; N_AUX_HORIZONS]>> =
|
||||
// SDD-3 CB2: aux outcome labels from the loader's A+B paired
|
||||
// generator — prof-binary + σ-normalized size targets per direction.
|
||||
// NaN slots are natively masked by the existing Huber loss kernel
|
||||
// (CB2 transition state: still routed through the D-era kernel via
|
||||
// the prof-binary head; CB5 rewrites the kernel to BCE+Huber properly).
|
||||
let mut out_prof_long_batch: Vec<Vec<[f32; N_AUX_HORIZONS]>> =
|
||||
Vec::with_capacity(cli.batch_size);
|
||||
let mut out_short_batch: Vec<Vec<[f32; N_AUX_HORIZONS]>> =
|
||||
let mut out_prof_short_batch: Vec<Vec<[f32; N_AUX_HORIZONS]>> =
|
||||
Vec::with_capacity(cli.batch_size);
|
||||
let mut out_size_long_batch: Vec<Vec<[f32; N_AUX_HORIZONS]>> =
|
||||
Vec::with_capacity(cli.batch_size);
|
||||
let mut out_size_short_batch: Vec<Vec<[f32; N_AUX_HORIZONS]>> =
|
||||
Vec::with_capacity(cli.batch_size);
|
||||
// pos_fraction is a file-level statistic; every sequence in the
|
||||
// current batch carries the per-source-file vector. The trainer
|
||||
// currently accepts a single `&[f32]` of length `2 * N_AUX_HORIZONS`
|
||||
// — broadcast the first sequence's vector across the batch (all
|
||||
// batched sequences may come from different files, so this is an
|
||||
// approximation that CB5 will refine via per-sample weighting).
|
||||
let mut batch_pos_fraction: Vec<f32> = vec![0.0_f32; 2 * N_AUX_HORIZONS];
|
||||
while let Some(seq) = train_loader.next_sequence().context("train next_seq")? {
|
||||
let mut labels_per_pos: Vec<[f32; N_HORIZONS]> = Vec::with_capacity(seq.snapshots.len());
|
||||
let mut any_finite = false;
|
||||
@@ -499,27 +513,43 @@ fn main() -> Result<()> {
|
||||
labels_per_pos.push(row);
|
||||
}
|
||||
if !any_finite { continue; }
|
||||
// SDD-3 Layer B5: pull D-style aux outcomes for both directions.
|
||||
// `seq.outcome_long[h]` / `seq.outcome_short[h]` are `Vec<f32>`
|
||||
// sliced per-anchor; convert to per-position rows mirroring the
|
||||
// BCE label layout (so the trainer's `step_batched` sees one
|
||||
// `[N_AUX_HORIZONS]` row per K position).
|
||||
let mut out_long_pos: Vec<[f32; N_AUX_HORIZONS]> =
|
||||
// SDD-3 CB2: convert per-(horizon, snapshot) loader columns into
|
||||
// per-snapshot `[N_AUX_HORIZONS]` rows mirroring the BCE label
|
||||
// layout (so the trainer's `step_batched` sees one row per K).
|
||||
let mut out_prof_long_pos: Vec<[f32; N_AUX_HORIZONS]> =
|
||||
Vec::with_capacity(seq.snapshots.len());
|
||||
let mut out_short_pos: Vec<[f32; N_AUX_HORIZONS]> =
|
||||
let mut out_prof_short_pos: Vec<[f32; N_AUX_HORIZONS]> =
|
||||
Vec::with_capacity(seq.snapshots.len());
|
||||
let mut out_size_long_pos: Vec<[f32; N_AUX_HORIZONS]> =
|
||||
Vec::with_capacity(seq.snapshots.len());
|
||||
let mut out_size_short_pos: Vec<[f32; N_AUX_HORIZONS]> =
|
||||
Vec::with_capacity(seq.snapshots.len());
|
||||
for k in 0..seq.snapshots.len() {
|
||||
let row_long: [f32; N_AUX_HORIZONS] =
|
||||
std::array::from_fn(|h| seq.outcome_long[h][k]);
|
||||
let row_short: [f32; N_AUX_HORIZONS] =
|
||||
std::array::from_fn(|h| seq.outcome_short[h][k]);
|
||||
out_long_pos.push(row_long);
|
||||
out_short_pos.push(row_short);
|
||||
let row_prof_long: [f32; N_AUX_HORIZONS] =
|
||||
std::array::from_fn(|h| seq.outcome_prof_long[h][k]);
|
||||
let row_prof_short: [f32; N_AUX_HORIZONS] =
|
||||
std::array::from_fn(|h| seq.outcome_prof_short[h][k]);
|
||||
let row_size_long: [f32; N_AUX_HORIZONS] =
|
||||
std::array::from_fn(|h| seq.outcome_size_long[h][k]);
|
||||
let row_size_short: [f32; N_AUX_HORIZONS] =
|
||||
std::array::from_fn(|h| seq.outcome_size_short[h][k]);
|
||||
out_prof_long_pos.push(row_prof_long);
|
||||
out_prof_short_pos.push(row_prof_short);
|
||||
out_size_long_pos.push(row_size_long);
|
||||
out_size_short_pos.push(row_size_short);
|
||||
}
|
||||
// Capture pos_fraction from the first sample of the batch;
|
||||
// overwrite on every accepted sample (CB5 will lift this to
|
||||
// per-sample weighting).
|
||||
if seq.pos_fraction.len() == batch_pos_fraction.len() {
|
||||
batch_pos_fraction.copy_from_slice(&seq.pos_fraction);
|
||||
}
|
||||
snap_batch.push(seq.snapshots);
|
||||
label_batch.push(labels_per_pos);
|
||||
out_long_batch.push(out_long_pos);
|
||||
out_short_batch.push(out_short_pos);
|
||||
out_prof_long_batch.push(out_prof_long_pos);
|
||||
out_prof_short_batch.push(out_prof_short_pos);
|
||||
out_size_long_batch.push(out_size_long_pos);
|
||||
out_size_short_batch.push(out_size_short_pos);
|
||||
if snap_batch.len() < cli.batch_size { continue; }
|
||||
|
||||
// Full batch ready — apply LR schedule, fire step.
|
||||
@@ -534,12 +564,24 @@ fn main() -> Result<()> {
|
||||
|
||||
let snap_refs: Vec<&[Mbp10RawInput]> = snap_batch.iter().map(|v| v.as_slice()).collect();
|
||||
let label_refs: Vec<&[[f32; N_HORIZONS]]> = label_batch.iter().map(|v| v.as_slice()).collect();
|
||||
let out_long_refs: Vec<&[[f32; N_AUX_HORIZONS]]> =
|
||||
out_long_batch.iter().map(|v| v.as_slice()).collect();
|
||||
let out_short_refs: Vec<&[[f32; N_AUX_HORIZONS]]> =
|
||||
out_short_batch.iter().map(|v| v.as_slice()).collect();
|
||||
let prof_long_refs: Vec<&[[f32; N_AUX_HORIZONS]]> =
|
||||
out_prof_long_batch.iter().map(|v| v.as_slice()).collect();
|
||||
let prof_short_refs: Vec<&[[f32; N_AUX_HORIZONS]]> =
|
||||
out_prof_short_batch.iter().map(|v| v.as_slice()).collect();
|
||||
let size_long_refs: Vec<&[[f32; N_AUX_HORIZONS]]> =
|
||||
out_size_long_batch.iter().map(|v| v.as_slice()).collect();
|
||||
let size_short_refs: Vec<&[[f32; N_AUX_HORIZONS]]> =
|
||||
out_size_short_batch.iter().map(|v| v.as_slice()).collect();
|
||||
let loss = trainer
|
||||
.step_batched(&snap_refs, &label_refs, &out_long_refs, &out_short_refs)
|
||||
.step_batched(
|
||||
&snap_refs,
|
||||
&label_refs,
|
||||
&prof_long_refs,
|
||||
&prof_short_refs,
|
||||
&size_long_refs,
|
||||
&size_short_refs,
|
||||
&batch_pos_fraction,
|
||||
)
|
||||
.context("train step_batched")?;
|
||||
epoch_train_loss += loss;
|
||||
epoch_train_steps += 1;
|
||||
@@ -560,8 +602,10 @@ fn main() -> Result<()> {
|
||||
}
|
||||
snap_batch.clear();
|
||||
label_batch.clear();
|
||||
out_long_batch.clear();
|
||||
out_short_batch.clear();
|
||||
out_prof_long_batch.clear();
|
||||
out_prof_short_batch.clear();
|
||||
out_size_long_batch.clear();
|
||||
out_size_short_batch.clear();
|
||||
}
|
||||
let epoch_avg = if epoch_train_steps > 0 {
|
||||
epoch_train_loss / epoch_train_steps as f32
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
//! SDD-3 Layer B4 — Aux heads (linear regression) + Huber loss.
|
||||
//!
|
||||
//! Sits on top of [`crate::cfc::AuxTrunk`] and predicts continuous-valued
|
||||
//! trade-outcome targets emitted by [`crate::multi_horizon_labels::generate_outcome_labels_d`]
|
||||
//! (the D-style asymmetric loss-aversion labels from SDD-3 Layer B1).
|
||||
//! trade-outcome targets emitted by [`crate::multi_horizon_labels::generate_outcome_labels_ab`]
|
||||
//! (Candidate-B paired prof-binary + size-regression labels from SDD-3 CB1).
|
||||
//!
|
||||
//! CB2 transition state: the head signature here is unchanged (still emits
|
||||
//! `y_{long,short}_hat[B, N]` regression outputs). CB5 will rewrite the loss
|
||||
//! kernel to consume the prof-binary head via BCE and the size head via Huber.
|
||||
//! Until then, the regression outputs are supervised against `y_prof_{dir}`
|
||||
//! through the existing Huber path — a holding-pattern signal, not the final
|
||||
//! A+B losses.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
|
||||
@@ -20,7 +20,7 @@ use rand_chacha::ChaCha8Rng;
|
||||
|
||||
use crate::cfc::snap_features::{Mbp10RawInput, ES_TICK_SIZE, REGIME_DIM};
|
||||
use crate::heads::N_HORIZONS;
|
||||
use crate::multi_horizon_labels::{generate_labels, generate_outcome_labels_d};
|
||||
use crate::multi_horizon_labels::{generate_labels, generate_outcome_labels_ab};
|
||||
|
||||
/// EMA smoothing factors. Half-life ≈ ln(2)/α snapshots.
|
||||
///
|
||||
@@ -171,19 +171,29 @@ pub struct LabeledSequence {
|
||||
/// forward window is outside the source file (right edge) or where
|
||||
/// the move is a tied price (drop, mirror `generate_labels` semantics).
|
||||
pub labels: [Vec<f32>; N_HORIZONS],
|
||||
/// `[N_HORIZONS][seq_len]` D-style asymmetric outcome labels for the
|
||||
/// long direction (Phase B aux supervision).
|
||||
/// `outcome_long[h][t] = (price[t+K] - price[t] - cost) -
|
||||
/// 1.5 × |max long drawdown over [t, t+K]|`.
|
||||
/// NaN at right-edge positions where the forward window is outside the
|
||||
/// source file. See [`crate::multi_horizon_labels::generate_outcome_labels_d`].
|
||||
pub outcome_long: [Vec<f32>; N_HORIZONS],
|
||||
/// `[N_HORIZONS][seq_len]` D-style asymmetric outcome labels for the
|
||||
/// short direction.
|
||||
/// `outcome_short[h][t] = (price[t] - price[t+K] - cost) -
|
||||
/// 1.5 × |max short drawup over [t, t+K]|`.
|
||||
/// Same NaN edge semantics as `outcome_long`.
|
||||
pub outcome_short: [Vec<f32>; N_HORIZONS],
|
||||
/// `[N_HORIZONS][seq_len]` Candidate-B prof-binary labels for the long
|
||||
/// direction (1.0 if `ΔP_K - 2×cost > 0`, 0.0 otherwise, NaN at right
|
||||
/// edge or non-finite price). See [`crate::multi_horizon_labels::generate_outcome_labels_ab`].
|
||||
pub outcome_prof_long: [Vec<f32>; N_HORIZONS],
|
||||
/// `[N_HORIZONS][seq_len]` Candidate-B prof-binary labels for the short
|
||||
/// direction.
|
||||
pub outcome_prof_short: [Vec<f32>; N_HORIZONS],
|
||||
/// `[N_HORIZONS][seq_len]` Candidate-B σ-normalized signed-return targets
|
||||
/// for the long direction. NaN where prof_long==0 or σ_K not yet warmed.
|
||||
pub outcome_size_long: [Vec<f32>; N_HORIZONS],
|
||||
/// `[N_HORIZONS][seq_len]` Candidate-B σ-normalized signed-return targets
|
||||
/// for the short direction. Same NaN semantics as `outcome_size_long`.
|
||||
pub outcome_size_short: [Vec<f32>; N_HORIZONS],
|
||||
/// `[N_HORIZONS][seq_len]` rolling 1000-step Welford std of K-step
|
||||
/// log-returns at each snapshot. NaN during the warmup window. Used
|
||||
/// downstream for σ-normalized size targets and any in-trainer scaling.
|
||||
pub sigma_k: [Vec<f32>; N_HORIZONS],
|
||||
/// Per-file class imbalance for `pos_weight` BCE balancing.
|
||||
/// Length = `2 * N_HORIZONS`, laid out as
|
||||
/// `[long_h0, long_h1, ..., long_hN, short_h0, ..., short_hN]`.
|
||||
/// File-level statistic — every sequence yielded from the same source
|
||||
/// file carries the same vector.
|
||||
pub pos_fraction: Vec<f32>,
|
||||
}
|
||||
|
||||
/// Loaded-file cache. Holds the deserialized snapshots + pre-computed
|
||||
@@ -195,13 +205,27 @@ struct LoadedFile {
|
||||
/// `cfg.horizons[h]`, indexed the same as `snapshots`. Sliced per
|
||||
/// anchor on each `next_sequence()` call.
|
||||
labels_full: [Vec<f32>; N_HORIZONS],
|
||||
/// `outcome_long_full[h]` holds the full-stream D-style asymmetric
|
||||
/// long-direction outcome label vector for horizon `cfg.horizons[h]`.
|
||||
/// `outcome_prof_long_full[h]` holds the full-stream Candidate-B
|
||||
/// prof-binary long-direction label vector for horizon `cfg.horizons[h]`.
|
||||
/// NaN at right-edge positions. Sliced per anchor alongside `labels_full`.
|
||||
outcome_long_full: [Vec<f32>; N_HORIZONS],
|
||||
/// `outcome_short_full[h]` — short-direction counterpart to
|
||||
/// `outcome_long_full`.
|
||||
outcome_short_full: [Vec<f32>; N_HORIZONS],
|
||||
outcome_prof_long_full: [Vec<f32>; N_HORIZONS],
|
||||
/// `outcome_prof_short_full[h]` — short-direction counterpart to
|
||||
/// `outcome_prof_long_full`.
|
||||
outcome_prof_short_full: [Vec<f32>; N_HORIZONS],
|
||||
/// `outcome_size_long_full[h]` holds the full-stream Candidate-B
|
||||
/// σ-normalized size target for the long direction. NaN where
|
||||
/// prof_long==0 OR σ_K not yet warmed.
|
||||
outcome_size_long_full: [Vec<f32>; N_HORIZONS],
|
||||
/// `outcome_size_short_full[h]` — short-direction counterpart.
|
||||
outcome_size_short_full: [Vec<f32>; N_HORIZONS],
|
||||
/// `sigma_k_full[h]` rolling 1000-step Welford std of K-step log-returns.
|
||||
/// NaN during the per-horizon warmup window.
|
||||
sigma_k_full: [Vec<f32>; N_HORIZONS],
|
||||
/// File-level positive-class fraction per (direction, horizon) for BCE
|
||||
/// pos_weight balancing. Length = `2 * N_HORIZONS`, laid out as
|
||||
/// `[long_h0..hN, short_h0..hN]`. Copied verbatim into every yielded
|
||||
/// `LabeledSequence`.
|
||||
pos_fraction: Vec<f32>,
|
||||
/// Per-snapshot regime context computed once per file via a forward
|
||||
/// EMA cascade. See [`compute_regime_features`]. Sliced per anchor.
|
||||
regime_full: Vec<[f32; REGIME_DIM]>,
|
||||
@@ -263,8 +287,14 @@ impl MultiHorizonLoader {
|
||||
continue;
|
||||
}
|
||||
let mut labels_full: [Vec<f32>; N_HORIZONS] = Default::default();
|
||||
let mut outcome_long_full: [Vec<f32>; N_HORIZONS] = Default::default();
|
||||
let mut outcome_short_full: [Vec<f32>; N_HORIZONS] = Default::default();
|
||||
let mut outcome_prof_long_full: [Vec<f32>; N_HORIZONS] = Default::default();
|
||||
let mut outcome_prof_short_full: [Vec<f32>; N_HORIZONS] = Default::default();
|
||||
let mut outcome_size_long_full: [Vec<f32>; N_HORIZONS] = Default::default();
|
||||
let mut outcome_size_short_full: [Vec<f32>; N_HORIZONS] = Default::default();
|
||||
let mut sigma_k_full: [Vec<f32>; N_HORIZONS] = Default::default();
|
||||
// Default pos_fraction is zero per direction/horizon; populated below in
|
||||
// training mode. Length is fixed so downstream broadcast is uniform.
|
||||
let mut pos_fraction: Vec<f32> = vec![0.0_f32; 2 * N_HORIZONS];
|
||||
if !cfg.inference_only {
|
||||
let prices: Vec<f32> = snapshots.iter().map(mid_price_f32).collect();
|
||||
for (h_idx, &h) in cfg.horizons.iter().enumerate() {
|
||||
@@ -275,28 +305,36 @@ impl MultiHorizonLoader {
|
||||
}
|
||||
labels_full[h_idx] = full;
|
||||
}
|
||||
// Phase B aux supervision: D-style asymmetric loss-aversion labels
|
||||
// generated from the same mid-price series. NaN at right-edge
|
||||
// positions is preserved verbatim from the generator.
|
||||
let outcome = generate_outcome_labels_d(
|
||||
// Candidate-B aux supervision: paired prof-binary + σ-normalized
|
||||
// size-regression targets per (direction, horizon). Replaces the
|
||||
// old D-style asymmetric loss-aversion labels.
|
||||
let outcome = generate_outcome_labels_ab(
|
||||
&prices,
|
||||
&cfg.horizons,
|
||||
cfg.outcome_label_cost,
|
||||
)
|
||||
.with_context(|| format!(
|
||||
"generate_outcome_labels_d for {} (cost={})",
|
||||
"generate_outcome_labels_ab for {} (cost={})",
|
||||
path.display(),
|
||||
cfg.outcome_label_cost,
|
||||
))?;
|
||||
outcome_long_full = outcome.y_long;
|
||||
outcome_short_full = outcome.y_short;
|
||||
outcome_prof_long_full = outcome.y_prof_long;
|
||||
outcome_prof_short_full = outcome.y_prof_short;
|
||||
outcome_size_long_full = outcome.y_size_long;
|
||||
outcome_size_short_full = outcome.y_size_short;
|
||||
sigma_k_full = outcome.sigma_k;
|
||||
pos_fraction = outcome.pos_fraction;
|
||||
}
|
||||
let regime_full = compute_regime_features(&snapshots);
|
||||
files_loaded.push(LoadedFile {
|
||||
snapshots,
|
||||
labels_full,
|
||||
outcome_long_full,
|
||||
outcome_short_full,
|
||||
outcome_prof_long_full,
|
||||
outcome_prof_short_full,
|
||||
outcome_size_long_full,
|
||||
outcome_size_short_full,
|
||||
sigma_k_full,
|
||||
pos_fraction,
|
||||
regime_full,
|
||||
});
|
||||
}
|
||||
@@ -419,20 +457,32 @@ impl MultiHorizonLoader {
|
||||
let anchor: usize = self.rng.gen_range(0..max_anchor);
|
||||
|
||||
let mut labels: [Vec<f32>; N_HORIZONS] = Default::default();
|
||||
let mut outcome_long: [Vec<f32>; N_HORIZONS] = Default::default();
|
||||
let mut outcome_short: [Vec<f32>; N_HORIZONS] = Default::default();
|
||||
let mut outcome_prof_long: [Vec<f32>; N_HORIZONS] = Default::default();
|
||||
let mut outcome_prof_short: [Vec<f32>; N_HORIZONS] = Default::default();
|
||||
let mut outcome_size_long: [Vec<f32>; N_HORIZONS] = Default::default();
|
||||
let mut outcome_size_short: [Vec<f32>; N_HORIZONS] = Default::default();
|
||||
let mut sigma_k: [Vec<f32>; N_HORIZONS] = Default::default();
|
||||
for h in 0..N_HORIZONS {
|
||||
let mut row = Vec::with_capacity(self.cfg.seq_len);
|
||||
let mut row_long = Vec::with_capacity(self.cfg.seq_len);
|
||||
let mut row_short = Vec::with_capacity(self.cfg.seq_len);
|
||||
let mut row_prof_long = Vec::with_capacity(self.cfg.seq_len);
|
||||
let mut row_prof_short = Vec::with_capacity(self.cfg.seq_len);
|
||||
let mut row_size_long = Vec::with_capacity(self.cfg.seq_len);
|
||||
let mut row_size_short = Vec::with_capacity(self.cfg.seq_len);
|
||||
let mut row_sigma = Vec::with_capacity(self.cfg.seq_len);
|
||||
for k in 0..self.cfg.seq_len {
|
||||
row.push(lf.labels_full[h][anchor + k]);
|
||||
row_long.push(lf.outcome_long_full[h][anchor + k]);
|
||||
row_short.push(lf.outcome_short_full[h][anchor + k]);
|
||||
row_prof_long.push(lf.outcome_prof_long_full[h][anchor + k]);
|
||||
row_prof_short.push(lf.outcome_prof_short_full[h][anchor + k]);
|
||||
row_size_long.push(lf.outcome_size_long_full[h][anchor + k]);
|
||||
row_size_short.push(lf.outcome_size_short_full[h][anchor + k]);
|
||||
row_sigma.push(lf.sigma_k_full[h][anchor + k]);
|
||||
}
|
||||
labels[h] = row;
|
||||
outcome_long[h] = row_long;
|
||||
outcome_short[h] = row_short;
|
||||
outcome_prof_long[h] = row_prof_long;
|
||||
outcome_prof_short[h] = row_prof_short;
|
||||
outcome_size_long[h] = row_size_long;
|
||||
outcome_size_short[h] = row_size_short;
|
||||
sigma_k[h] = row_sigma;
|
||||
}
|
||||
let mut sequence = Vec::with_capacity(self.cfg.seq_len);
|
||||
for k in 0..self.cfg.seq_len {
|
||||
@@ -447,8 +497,12 @@ impl MultiHorizonLoader {
|
||||
Ok(Some(LabeledSequence {
|
||||
snapshots: sequence,
|
||||
labels,
|
||||
outcome_long,
|
||||
outcome_short,
|
||||
outcome_prof_long,
|
||||
outcome_prof_short,
|
||||
outcome_size_long,
|
||||
outcome_size_short,
|
||||
sigma_k,
|
||||
pos_fraction: lf.pos_fraction.clone(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,14 +13,26 @@
|
||||
//! which is hardwired to `Phase1aConfig::horizon` and has Phase 1a
|
||||
//! corruption-audit assumptions baked in.
|
||||
//!
|
||||
//! Phase B.B1 (2026-05-22) — D-style asymmetric loss-aversion labels.
|
||||
//! Phase B.CB1 (2026-05-22) — Candidate-B paired aux labels (A+B pivot).
|
||||
//!
|
||||
//! [`generate_outcome_labels_d`] produces per-`(t, horizon, direction)`
|
||||
//! cost-aware labels that embed the trading objective directly into the
|
||||
//! supervision signal: realized PnL minus round-trip cost, penalised by
|
||||
//! `1.5 × max-adverse-excursion` along the holding path. Used as aux
|
||||
//! supervision targets by the trade-outcome head (wired in subsequent
|
||||
//! tasks).
|
||||
//! [`generate_outcome_labels_ab`] produces per-`(t, horizon, direction)`
|
||||
//! labels split into two complementary supervision signals:
|
||||
//!
|
||||
//! - **A (binary profitable)**: `y_prof_{long,short}[h][t] ∈ {0.0, 1.0}` —
|
||||
//! one iff `direction × ΔP_K - 2×cost > 0`. Trained with class-weighted
|
||||
//! BCE (`pos_fraction` field exposes the per-(direction,horizon) imbalance
|
||||
//! for `pos_weight` selection in the loss kernel).
|
||||
//! - **B (σ-normalized magnitude)**: `y_size_{long,short}[h][t] =
|
||||
//! (direction × ΔP_K) / σ_K[t]`, masked (NaN) where `y_prof == 0`. Trained
|
||||
//! with conditional Huber over the profitable subset only.
|
||||
//!
|
||||
//! `σ_K[t]` is a rolling-1000-snapshot Welford std of K-step log-returns,
|
||||
//! floored at `cost / 4` (per `pearl_trade_level_vol_for_stop_distance` — the
|
||||
//! cost tick scale is the structural noise floor below which σ_K is degenerate).
|
||||
//!
|
||||
//! This replaces the D-style asymmetric loss-aversion labels which were
|
||||
//! empirically 94-99.99% negative on real ES MBP-10 data (un-learnable for
|
||||
//! a flat aux head).
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use std::collections::VecDeque;
|
||||
@@ -79,33 +91,49 @@ pub fn generate_labels(prices: &[f32], k: usize) -> LongHorizonLabels {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-(direction, horizon) cost-aware asymmetric loss-aversion labels.
|
||||
/// Used as aux supervision target for the trade-outcome head.
|
||||
pub struct OutcomeLabelsD {
|
||||
/// `y_long[h][t]` = (price[t+K] - price[t] - cost) - 1.5 * |max_drawdown_long|
|
||||
/// NaN at indices where the forward window is outside the source file (right
|
||||
/// edge) — same edge semantics as `generate_labels`.
|
||||
pub y_long: [Vec<f32>; N_HORIZONS],
|
||||
/// `y_short[h][t]` = (price[t] - price[t+K] - cost) - 1.5 * |max_drawup_short|
|
||||
pub y_short: [Vec<f32>; N_HORIZONS],
|
||||
/// Round-trip cost in price units (e.g. 0.5 for ES at 2-tick cost).
|
||||
/// Rolling Welford window size for σ_K computation. 1000 K-step log-returns is
|
||||
/// long enough for stable variance, short enough to track intraday regime drift.
|
||||
const SIGMA_WINDOW: usize = 1000;
|
||||
|
||||
/// Per-(direction, horizon) Candidate-B aux supervision targets:
|
||||
/// - `y_prof_{long,short}[h][t]` ∈ {0.0, 1.0, NaN}: profitable-after-2×cost binary.
|
||||
/// `y_prof_{dir}[h][t] = 1.0` iff `direction × ΔP_K - 2×cost > 0`.
|
||||
/// - `y_size_{long,short}[h][t]`: σ-normalized signed return `(direction × ΔP_K) / σ_K[t]`.
|
||||
/// NaN at right-edge OR when `y_prof_{dir}[h][t] == 0` (caller masks size loss on un-prof).
|
||||
/// - `sigma_k[h][t]`: rolling-1000 Welford std of K-step log-returns at time t, used for
|
||||
/// normalization. NaN until the warmup window fills.
|
||||
pub struct OutcomeLabelsAB {
|
||||
pub y_prof_long: [Vec<f32>; N_HORIZONS],
|
||||
pub y_prof_short: [Vec<f32>; N_HORIZONS],
|
||||
pub y_size_long: [Vec<f32>; N_HORIZONS],
|
||||
pub y_size_short: [Vec<f32>; N_HORIZONS],
|
||||
pub sigma_k: [Vec<f32>; N_HORIZONS],
|
||||
pub cost_price_units: f32,
|
||||
/// Pos-class fraction per horizon per direction, computed at generation time
|
||||
/// for downstream `pos_weight` BCE balancing. Length = `2 * N_HORIZONS`,
|
||||
/// laid out as `[long_h0, long_h1, ..., short_h0, short_h1, ...]`.
|
||||
pub pos_fraction: Vec<f32>,
|
||||
}
|
||||
|
||||
/// D-style asymmetric loss-aversion label generator.
|
||||
/// Candidate-B (A+B paired) label generator.
|
||||
///
|
||||
/// Per (snapshot t, horizon K, direction d):
|
||||
/// profit = signed_pnl(d) - cost
|
||||
/// dd_against = max adverse excursion in [t, t+K] when holding direction d
|
||||
/// y[t, K, d] = profit - 1.5 × |dd_against|
|
||||
/// Per (snapshot t, horizon K, direction d ∈ {+1, -1}):
|
||||
/// pnl_d = d × (prices[t+K] - prices[t]) - cost (cost already netted once)
|
||||
/// y_prof = 1.0 if pnl_d > cost else 0.0 (i.e. signed_pnl > 2×cost)
|
||||
/// y_size = pnl_d / σ_K[t] if y_prof == 1.0 else NaN
|
||||
///
|
||||
/// Uses monotonic-deque sliding-window min/max for O(N) per horizon.
|
||||
/// `prices` is the bar/snapshot-aligned mid-price series.
|
||||
pub fn generate_outcome_labels_d(
|
||||
/// σ_K[t] is a 1000-step rolling Welford std of K-step log-returns
|
||||
/// `ln(p[t+K] / p[t])`, floored at `cost_price_units / 4` per
|
||||
/// `pearl_trade_level_vol_for_stop_distance` (cost tick scale = structural noise
|
||||
/// floor; below this σ_K is degenerate noise).
|
||||
///
|
||||
/// `pos_fraction` is computed over finite-y_prof entries only and consumed by the
|
||||
/// loss kernel for class-weighted BCE.
|
||||
pub fn generate_outcome_labels_ab(
|
||||
prices: &[f32],
|
||||
horizons: &[usize; N_HORIZONS],
|
||||
cost_price_units: f32,
|
||||
) -> Result<OutcomeLabelsD> {
|
||||
) -> Result<OutcomeLabelsAB> {
|
||||
if !cost_price_units.is_finite() || cost_price_units < 0.0 {
|
||||
bail!(
|
||||
"cost_price_units must be finite and non-negative, got {}",
|
||||
@@ -119,129 +147,138 @@ pub fn generate_outcome_labels_d(
|
||||
}
|
||||
|
||||
let n = prices.len();
|
||||
// SAFETY: NaN-initialised then overwritten in place; no MaybeUninit needed.
|
||||
let mut y_long: [Vec<f32>; N_HORIZONS] = std::array::from_fn(|_| vec![f32::NAN; n]);
|
||||
let mut y_short: [Vec<f32>; N_HORIZONS] = std::array::from_fn(|_| vec![f32::NAN; n]);
|
||||
let mut y_prof_long: [Vec<f32>; N_HORIZONS] = std::array::from_fn(|_| vec![f32::NAN; n]);
|
||||
let mut y_prof_short: [Vec<f32>; N_HORIZONS] = std::array::from_fn(|_| vec![f32::NAN; n]);
|
||||
let mut y_size_long: [Vec<f32>; N_HORIZONS] = std::array::from_fn(|_| vec![f32::NAN; n]);
|
||||
let mut y_size_short: [Vec<f32>; N_HORIZONS] = std::array::from_fn(|_| vec![f32::NAN; n]);
|
||||
let mut sigma_k: [Vec<f32>; N_HORIZONS] = std::array::from_fn(|_| vec![f32::NAN; n]);
|
||||
let mut pos_fraction = vec![0.0_f32; 2 * N_HORIZONS];
|
||||
|
||||
// σ_K floor — cost tick scale is the structural noise floor below which
|
||||
// variance is degenerate (pearl_trade_level_vol_for_stop_distance). Use cost/4
|
||||
// as the conservative floor so size ratio remains bounded even on
|
||||
// pathological constant-price segments.
|
||||
let sigma_floor = cost_price_units / 4.0;
|
||||
|
||||
for (h, &k) in horizons.iter().enumerate() {
|
||||
if n <= k {
|
||||
continue;
|
||||
}
|
||||
let win_min = forward_window_min(prices, k);
|
||||
let win_max = forward_window_max(prices, k);
|
||||
// Valid range for full forward window: t in 0..=n-1-k, i.e. t+k <= n-1.
|
||||
|
||||
// Pass 1: rolling Welford std of log-returns r[t] = ln(p[t+K] / p[t]).
|
||||
// Maintain a window of the last SIGMA_WINDOW finite log-returns and
|
||||
// recompute mean/var on the window. Window is small (1000) and N
|
||||
// typically <= a few hundred K, so the O(W) per step is acceptable
|
||||
// and avoids the numerical instability of subtracting popped values
|
||||
// from a streaming sum-of-squares.
|
||||
let mut window: VecDeque<f32> = VecDeque::with_capacity(SIGMA_WINDOW);
|
||||
for t in 0..n - k {
|
||||
let p_t = prices[t];
|
||||
let p_kt = prices[t + k];
|
||||
// Guard against non-finite inputs anywhere in the path. The min/max
|
||||
// helpers propagate NaN naturally (NaN compares false; deque keeps it),
|
||||
// so we re-check on output here for clarity.
|
||||
if !p_t.is_finite()
|
||||
|| !p_kt.is_finite()
|
||||
|| !win_min[t].is_finite()
|
||||
|| !win_max[t].is_finite()
|
||||
{
|
||||
// Compute σ_K[t] BEFORE pushing r[t] into the window, so σ_K[t] is
|
||||
// strictly causal (no peek at the future bar's return). Window
|
||||
// therefore must have warmed up via prior iterations.
|
||||
if window.len() >= 2 {
|
||||
// Sample variance via two-pass mean/var on the window (small W,
|
||||
// accuracy > speed). Welford's online form would be marginal here.
|
||||
let w_len = window.len() as f32;
|
||||
let mean: f32 = window.iter().sum::<f32>() / w_len;
|
||||
let var: f32 = window
|
||||
.iter()
|
||||
.map(|&x| {
|
||||
let d = x - mean;
|
||||
d * d
|
||||
})
|
||||
.sum::<f32>()
|
||||
/ (w_len - 1.0);
|
||||
let std = var.sqrt();
|
||||
sigma_k[h][t] = std.max(sigma_floor);
|
||||
} else if sigma_floor > 0.0 {
|
||||
// Even with no window data, expose the structural floor so
|
||||
// downstream code can normalize meaningfully once cost > 0.
|
||||
// With cost == 0 (test path) we leave NaN so y_size is masked.
|
||||
sigma_k[h][t] = sigma_floor;
|
||||
}
|
||||
// Push r[t] into the window for FUTURE steps.
|
||||
if p_t.is_finite() && p_kt.is_finite() && p_t > 0.0 && p_kt > 0.0 {
|
||||
let r = (p_kt / p_t).ln();
|
||||
if r.is_finite() {
|
||||
if window.len() == SIGMA_WINDOW {
|
||||
window.pop_front();
|
||||
}
|
||||
window.push_back(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: populate prof + size labels using σ_K[t] and ΔP_K.
|
||||
for t in 0..n - k {
|
||||
let p_t = prices[t];
|
||||
let p_kt = prices[t + k];
|
||||
if !p_t.is_finite() || !p_kt.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let delta = p_kt - p_t;
|
||||
let dd_long = (p_t - win_min[t]).max(0.0);
|
||||
let dd_short = (win_max[t] - p_t).max(0.0);
|
||||
y_long[h][t] = (delta - cost_price_units) - 1.5 * dd_long;
|
||||
y_short[h][t] = (-delta - cost_price_units) - 1.5 * dd_short;
|
||||
let pnl_long = delta - cost_price_units;
|
||||
let pnl_short = -delta - cost_price_units;
|
||||
|
||||
// y_prof: profitable iff signed_pnl > 2×cost, i.e. pnl_{dir} > cost.
|
||||
let prof_long = if pnl_long > cost_price_units {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let prof_short = if pnl_short > cost_price_units {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
y_prof_long[h][t] = prof_long;
|
||||
y_prof_short[h][t] = prof_short;
|
||||
|
||||
// y_size: only defined where profitable AND σ_K finite + positive.
|
||||
let sigma = sigma_k[h][t];
|
||||
if sigma.is_finite() && sigma > 0.0 {
|
||||
if prof_long > 0.5 {
|
||||
y_size_long[h][t] = pnl_long / sigma;
|
||||
}
|
||||
if prof_short > 0.5 {
|
||||
y_size_short[h][t] = pnl_short / sigma;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pos_fraction: finite-only count of positives over finite total, per dir.
|
||||
let count = |arr: &[f32]| -> (usize, usize) {
|
||||
let mut pos = 0_usize;
|
||||
let mut tot = 0_usize;
|
||||
for &v in arr {
|
||||
if v.is_finite() {
|
||||
tot += 1;
|
||||
if v > 0.5 {
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
(pos, tot)
|
||||
};
|
||||
let (lp, lt) = count(&y_prof_long[h]);
|
||||
let (sp, st) = count(&y_prof_short[h]);
|
||||
pos_fraction[h] = if lt > 0 { lp as f32 / lt as f32 } else { 0.0 };
|
||||
pos_fraction[N_HORIZONS + h] = if st > 0 { sp as f32 / st as f32 } else { 0.0 };
|
||||
}
|
||||
|
||||
Ok(OutcomeLabelsD {
|
||||
y_long,
|
||||
y_short,
|
||||
Ok(OutcomeLabelsAB {
|
||||
y_prof_long,
|
||||
y_prof_short,
|
||||
y_size_long,
|
||||
y_size_short,
|
||||
sigma_k,
|
||||
cost_price_units,
|
||||
pos_fraction,
|
||||
})
|
||||
}
|
||||
|
||||
/// Forward sliding-window minimum: `out[t] = min(prices[t..=t+k])` for
|
||||
/// `t + k < n`, and `NaN` for the right-edge positions where the window
|
||||
/// extends past the end of the input.
|
||||
fn forward_window_min(prices: &[f32], k: usize) -> Vec<f32> {
|
||||
let n = prices.len();
|
||||
let mut out = vec![f32::NAN; n];
|
||||
if n == 0 || k >= n {
|
||||
return out;
|
||||
}
|
||||
let mut dq: VecDeque<usize> = VecDeque::new();
|
||||
// Seed the deque with the first window [0..=k].
|
||||
for j in 0..=k {
|
||||
while let Some(&back) = dq.back() {
|
||||
if prices[back] >= prices[j] {
|
||||
dq.pop_back();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
dq.push_back(j);
|
||||
}
|
||||
out[0] = prices[*dq.front().expect("deque non-empty after seeding")];
|
||||
// Slide forward: at each step drop the front if it leaves [t..=t+k],
|
||||
// then push the new right edge (t+k).
|
||||
for t in 1..n - k {
|
||||
let new_j = t + k;
|
||||
if let Some(&front) = dq.front() {
|
||||
if front < t {
|
||||
dq.pop_front();
|
||||
}
|
||||
}
|
||||
while let Some(&back) = dq.back() {
|
||||
if prices[back] >= prices[new_j] {
|
||||
dq.pop_back();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
dq.push_back(new_j);
|
||||
out[t] = prices[*dq.front().expect("deque non-empty after slide")];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Forward sliding-window maximum: `out[t] = max(prices[t..=t+k])` for
|
||||
/// `t + k < n`, and `NaN` for the right-edge positions.
|
||||
fn forward_window_max(prices: &[f32], k: usize) -> Vec<f32> {
|
||||
let n = prices.len();
|
||||
let mut out = vec![f32::NAN; n];
|
||||
if n == 0 || k >= n {
|
||||
return out;
|
||||
}
|
||||
let mut dq: VecDeque<usize> = VecDeque::new();
|
||||
for j in 0..=k {
|
||||
while let Some(&back) = dq.back() {
|
||||
if prices[back] <= prices[j] {
|
||||
dq.pop_back();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
dq.push_back(j);
|
||||
}
|
||||
out[0] = prices[*dq.front().expect("deque non-empty after seeding")];
|
||||
for t in 1..n - k {
|
||||
let new_j = t + k;
|
||||
if let Some(&front) = dq.front() {
|
||||
if front < t {
|
||||
dq.pop_front();
|
||||
}
|
||||
}
|
||||
while let Some(&back) = dq.back() {
|
||||
if prices[back] <= prices[new_j] {
|
||||
dq.pop_back();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
dq.push_back(new_j);
|
||||
out[t] = prices[*dq.front().expect("deque non-empty after slide")];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -303,96 +340,235 @@ mod tests {
|
||||
assert!(out.labels.iter().all(|&y| y == 0.0 || y == 1.0));
|
||||
}
|
||||
|
||||
// ===== A+B (Candidate-B) tests =====
|
||||
|
||||
#[test]
|
||||
fn d_labels_match_hand_derivation_simple_up() {
|
||||
let prices = vec![100.0, 101.0]; // 1.0 up in 1 step
|
||||
let labels = generate_outcome_labels_d(&prices, &[1; N_HORIZONS], 0.5).unwrap();
|
||||
// K=1, t=0: ΔP = +1.0; cost = 0.5
|
||||
// y_long = (1.0 - 0.5) - 1.5 × max(0, 100 - min(100, 101)) = 0.5 - 0 = 0.5
|
||||
// y_short = (-1.0 - 0.5) - 1.5 × max(0, max(100, 101) - 100) = -1.5 - 1.5 = -3.0
|
||||
assert!((labels.y_long[0][0] - 0.5).abs() < 1e-6);
|
||||
assert!((labels.y_short[0][0] - (-3.0)).abs() < 1e-6);
|
||||
fn ab_labels_match_hand_derivation_simple_up() {
|
||||
// prices=[100, 102], K=1, cost=0.5
|
||||
// pnl_long = (102-100) - 0.5 = 1.5 > 0.5 → y_prof_long = 1
|
||||
// pnl_short = -2 - 0.5 = -2.5 ≤ 0.5 → y_prof_short = 0
|
||||
// With only one K-step return (and window pushes happen AFTER read),
|
||||
// σ_K[0] has zero window samples → falls back to cost/4 = 0.125.
|
||||
// y_size_long = 1.5 / 0.125 = 12.0 (size defined since prof==1).
|
||||
// y_size_short = NaN (prof==0 masks it).
|
||||
let labels = generate_outcome_labels_ab(&[100.0, 102.0], &[1; N_HORIZONS], 0.5).unwrap();
|
||||
assert_eq!(labels.y_prof_long[0][0], 1.0);
|
||||
assert_eq!(labels.y_prof_short[0][0], 0.0);
|
||||
// size: defined iff prof==1 AND sigma finite. Here sigma = floor = 0.125.
|
||||
assert!(labels.y_size_long[0][0].is_finite());
|
||||
assert!(labels.y_size_short[0][0].is_nan());
|
||||
// Right edge: index 1 (t+K = 2 = n) has no forward window → NaN prof, NaN size.
|
||||
assert!(labels.y_prof_long[0][1].is_nan());
|
||||
assert!(labels.y_size_long[0][1].is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn d_labels_penalize_drawdown_before_recovery() {
|
||||
let prices = vec![100.0, 99.0, 100.0, 101.0]; // drops then recovers; ΔP_K=3 = +1.0
|
||||
let labels = generate_outcome_labels_d(&prices, &[3; N_HORIZONS], 0.5).unwrap();
|
||||
// K=3, t=0: ΔP = +1.0; cost = 0.5
|
||||
// y_long: dd_against = 100 - min(100, 99, 100, 101) = 100 - 99 = 1.0
|
||||
// y_long = (1.0 - 0.5) - 1.5 × 1.0 = 0.5 - 1.5 = -1.0
|
||||
// y_short: dd_against = max(100, 99, 100, 101) - 100 = 1.0
|
||||
// y_short = (-1.0 - 0.5) - 1.5 × 1.0 = -1.5 - 1.5 = -3.0
|
||||
assert!((labels.y_long[0][0] - (-1.0)).abs() < 1e-6);
|
||||
assert!((labels.y_short[0][0] - (-3.0)).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn d_labels_nan_at_right_edge() {
|
||||
let prices = vec![100.0; 5];
|
||||
let labels = generate_outcome_labels_d(&prices, &[3; N_HORIZONS], 0.5).unwrap();
|
||||
// For K=3, indices 0..=1 have full window; indices 2,3,4 lack t+K
|
||||
assert!(labels.y_long[0][0].is_finite());
|
||||
assert!(labels.y_long[0][1].is_finite());
|
||||
assert!(labels.y_long[0][2].is_nan());
|
||||
assert!(labels.y_long[0][3].is_nan());
|
||||
assert!(labels.y_long[0][4].is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn d_labels_sliding_window_min_max_correct() {
|
||||
let prices = vec![5.0, 3.0, 8.0, 1.0, 6.0];
|
||||
let labels = generate_outcome_labels_d(&prices, &[3; N_HORIZONS], 0.0).unwrap();
|
||||
// K=3, t=0: window [5,3,8,1]; ΔP = 1-5 = -4
|
||||
// y_long: dd_against = 5 - min(5,3,8,1) = 5 - 1 = 4
|
||||
// y_long = (-4 - 0) - 1.5 × 4 = -10
|
||||
// y_short: dd_against = max(5,3,8,1) - 5 = 8 - 5 = 3
|
||||
// y_short = (4 - 0) - 1.5 × 3 = -0.5
|
||||
assert!((labels.y_long[0][0] - (-10.0)).abs() < 1e-6);
|
||||
assert!((labels.y_short[0][0] - (-0.5)).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn d_labels_per_horizon_independent() {
|
||||
// Monotonic +0.5 per step.
|
||||
let prices: Vec<f32> = (0..20).map(|i| 100.0 + (i as f32 * 0.5)).collect();
|
||||
let horizons = [2, 5, 10];
|
||||
let labels = generate_outcome_labels_d(&prices, &horizons, 0.0).unwrap();
|
||||
// K=2: ΔP = 2 × 0.5 = 1.0; dd_against_long = 0 (monotonic up)
|
||||
// y_long[0][0] = 1.0 - 0 = 1.0
|
||||
// K=5: ΔP = 2.5; y_long[1][0] = 2.5
|
||||
// K=10: ΔP = 5.0; y_long[2][0] = 5.0
|
||||
assert!((labels.y_long[0][0] - 1.0).abs() < 1e-6);
|
||||
assert!((labels.y_long[1][0] - 2.5).abs() < 1e-6);
|
||||
assert!((labels.y_long[2][0] - 5.0).abs() < 1e-6);
|
||||
// Short on monotonic up should be strictly negative at every horizon.
|
||||
assert!(labels.y_short[0][0] < 0.0);
|
||||
assert!(labels.y_short[1][0] < 0.0);
|
||||
assert!(labels.y_short[2][0] < 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn d_labels_long_short_invariant_on_constant_series() {
|
||||
// Constant prices: ΔP = 0, dd = 0 for both sides → both labels = -cost.
|
||||
let prices = vec![100.0_f32; 10];
|
||||
let labels = generate_outcome_labels_d(&prices, &[3; N_HORIZONS], 0.5).unwrap();
|
||||
for t in 0..7 {
|
||||
assert!((labels.y_long[0][t] - (-0.5)).abs() < 1e-6);
|
||||
assert!((labels.y_short[0][t] - (-0.5)).abs() < 1e-6);
|
||||
fn ab_labels_balanced_distribution_50_50() {
|
||||
// Synthetic alternating ±1 walk of length 2002 at K=1, cost=0.0.
|
||||
// At each t, ΔP alternates +1, -1; pnl_long > 0 on the +1 steps,
|
||||
// pnl_short > 0 on the -1 steps. Strictly > cost = 0 means exactly
|
||||
// half of t are y_prof_long=1, the other half y_prof_short=1.
|
||||
let mut prices = Vec::with_capacity(2002);
|
||||
let mut p = 100.0_f32;
|
||||
prices.push(p);
|
||||
for i in 0..2001 {
|
||||
p += if i % 2 == 0 { 1.0 } else { -1.0 };
|
||||
prices.push(p);
|
||||
}
|
||||
let labels = generate_outcome_labels_ab(&prices, &[1; N_HORIZONS], 0.0).unwrap();
|
||||
// pos_fraction layout: [long_h0..h2, short_h0..h2]
|
||||
let plong = labels.pos_fraction[0];
|
||||
let pshort = labels.pos_fraction[N_HORIZONS];
|
||||
// Expect ~50/50 within a tight tolerance (perfectly balanced construction).
|
||||
assert!(
|
||||
(plong - 0.5).abs() < 0.01,
|
||||
"long pos_fraction={} not ~0.5",
|
||||
plong
|
||||
);
|
||||
assert!(
|
||||
(pshort - 0.5).abs() < 0.01,
|
||||
"short pos_fraction={} not ~0.5",
|
||||
pshort
|
||||
);
|
||||
// Invariant: each finite (t,dir) is profitable for exactly one direction.
|
||||
let h = 0;
|
||||
for t in 0..prices.len() - 1 {
|
||||
let l = labels.y_prof_long[h][t];
|
||||
let s = labels.y_prof_short[h][t];
|
||||
if l.is_finite() && s.is_finite() {
|
||||
assert!(
|
||||
(l + s - 1.0).abs() < 1e-6,
|
||||
"exactly one direction must be profitable at t={} (l={}, s={})",
|
||||
t,
|
||||
l,
|
||||
s
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn d_labels_reject_zero_horizon() {
|
||||
fn ab_labels_warmup_window_produces_nan_sigma() {
|
||||
// With cost=0 the floor is 0, so the warmup region (< 2 samples in window)
|
||||
// leaves σ_K at NaN, which in turn forces y_size to NaN regardless of y_prof.
|
||||
// Construct a long enough series that at least the first few t's have an
|
||||
// empty window (window pushes happen AFTER read of σ_K[t]).
|
||||
let prices: Vec<f32> = (0..50).map(|i| 100.0 + (i as f32 * 0.1)).collect();
|
||||
let labels = generate_outcome_labels_ab(&prices, &[1; N_HORIZONS], 0.0).unwrap();
|
||||
// t=0: window is empty when σ_K[0] is computed, cost=0 so no floor → NaN.
|
||||
assert!(labels.sigma_k[0][0].is_nan());
|
||||
// y_size at t=0 must be NaN (sigma NaN), regardless of prof.
|
||||
assert!(labels.y_size_long[0][0].is_nan());
|
||||
assert!(labels.y_size_short[0][0].is_nan());
|
||||
// By t=2 the window has >= 2 samples → sigma_k becomes finite.
|
||||
assert!(labels.sigma_k[0][2].is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ab_labels_sigma_floor_at_cost_scale() {
|
||||
// Constant prices → all log-returns are zero → variance = 0 → std = 0.
|
||||
// The floor must lift σ_K to cost/4. Use cost=0.4 so floor=0.1.
|
||||
let prices = vec![100.0_f32; 200];
|
||||
let cost = 0.4_f32;
|
||||
let labels = generate_outcome_labels_ab(&prices, &[1; N_HORIZONS], cost).unwrap();
|
||||
let floor = cost / 4.0;
|
||||
// Mid-range t values have a non-empty window, so σ_K is computed and floored.
|
||||
// (Edge cases like t=0 use the structural floor directly via the else branch.)
|
||||
for &t in &[0_usize, 5, 50, 100, 150] {
|
||||
let s = labels.sigma_k[0][t];
|
||||
assert!(
|
||||
s.is_finite() && (s - floor).abs() < 1e-6,
|
||||
"sigma_k[0][{}] = {} expected floor = {}",
|
||||
t,
|
||||
s,
|
||||
floor
|
||||
);
|
||||
}
|
||||
// Constant prices: ΔP = 0; pnl_long = -cost, pnl_short = -cost; both ≤ cost
|
||||
// → y_prof = 0 everywhere → y_size = NaN everywhere.
|
||||
for t in 0..prices.len() - 1 {
|
||||
assert_eq!(labels.y_prof_long[0][t], 0.0);
|
||||
assert_eq!(labels.y_prof_short[0][t], 0.0);
|
||||
assert!(labels.y_size_long[0][t].is_nan());
|
||||
assert!(labels.y_size_short[0][t].is_nan());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ab_labels_cost_threshold_excludes_breakeven() {
|
||||
// prices=[100, 100.5, 101], K=1, cost=0.5
|
||||
// t=0: ΔP = 0.5; pnl_long = 0.5 - 0.5 = 0.0; NOT > cost (0.5) → y_prof_long=0
|
||||
// pnl_short = -0.5 - 0.5 = -1.0; ≤ cost → y_prof_short=0
|
||||
// t=1: ΔP = 0.5; pnl_long = 0.0; same → y_prof_long=0
|
||||
let prices = vec![100.0_f32, 100.5, 101.0];
|
||||
let labels = generate_outcome_labels_ab(&prices, &[1; N_HORIZONS], 0.5).unwrap();
|
||||
assert_eq!(labels.y_prof_long[0][0], 0.0);
|
||||
assert_eq!(labels.y_prof_short[0][0], 0.0);
|
||||
assert_eq!(labels.y_prof_long[0][1], 0.0);
|
||||
assert_eq!(labels.y_prof_short[0][1], 0.0);
|
||||
// Size masked when prof == 0.
|
||||
assert!(labels.y_size_long[0][0].is_nan());
|
||||
assert!(labels.y_size_long[0][1].is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ab_labels_pos_fraction_per_horizon() {
|
||||
// 10-step ramp up by 1.0/step at K=1, cost=0.0:
|
||||
// pnl_long = +1.0 > 0 → y_prof_long = 1 at every valid t (9 of them).
|
||||
// pnl_short = -1.0 ≤ 0 → y_prof_short = 0 at every valid t.
|
||||
// pos_fraction[long, h=0] should be 1.0; [short, h=0] should be 0.0.
|
||||
let prices: Vec<f32> = (0..10).map(|i| 100.0 + i as f32).collect();
|
||||
let labels = generate_outcome_labels_ab(&prices, &[1; N_HORIZONS], 0.0).unwrap();
|
||||
// Manual count.
|
||||
let mut pos_l = 0_usize;
|
||||
let mut tot_l = 0_usize;
|
||||
let mut pos_s = 0_usize;
|
||||
let mut tot_s = 0_usize;
|
||||
for t in 0..prices.len() {
|
||||
let l = labels.y_prof_long[0][t];
|
||||
let s = labels.y_prof_short[0][t];
|
||||
if l.is_finite() {
|
||||
tot_l += 1;
|
||||
if l > 0.5 {
|
||||
pos_l += 1;
|
||||
}
|
||||
}
|
||||
if s.is_finite() {
|
||||
tot_s += 1;
|
||||
if s > 0.5 {
|
||||
pos_s += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let expected_long = pos_l as f32 / tot_l as f32;
|
||||
let expected_short = pos_s as f32 / tot_s as f32;
|
||||
assert!(
|
||||
(labels.pos_fraction[0] - expected_long).abs() < 1e-6,
|
||||
"long pos_fraction mismatch: field={} manual={}",
|
||||
labels.pos_fraction[0],
|
||||
expected_long
|
||||
);
|
||||
assert!(
|
||||
(labels.pos_fraction[N_HORIZONS] - expected_short).abs() < 1e-6,
|
||||
"short pos_fraction mismatch: field={} manual={}",
|
||||
labels.pos_fraction[N_HORIZONS],
|
||||
expected_short
|
||||
);
|
||||
// Invariant on a strict-up ramp: long is universally profitable, short never.
|
||||
assert!((labels.pos_fraction[0] - 1.0).abs() < 1e-6);
|
||||
assert!(labels.pos_fraction[N_HORIZONS].abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ab_labels_size_sign_matches_direction() {
|
||||
// Strict-up ramp, K=1, cost=0.0: every long is profitable, sizes are
|
||||
// strictly positive (pnl_long = +ΔP / σ_K > 0).
|
||||
let prices: Vec<f32> = (0..2000).map(|i| 100.0 + i as f32 * 0.1).collect();
|
||||
let labels = generate_outcome_labels_ab(&prices, &[1; N_HORIZONS], 0.0).unwrap();
|
||||
let h = 0;
|
||||
let mut checked = 0;
|
||||
for t in 100..prices.len() - 1 {
|
||||
if labels.y_size_long[h][t].is_finite() {
|
||||
assert!(
|
||||
labels.y_size_long[h][t] > 0.0,
|
||||
"y_size_long at t={} = {} should be positive",
|
||||
t,
|
||||
labels.y_size_long[h][t]
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
// Short is unprofitable on a strict-up ramp → y_size_short stays NaN.
|
||||
assert!(labels.y_size_short[h][t].is_nan());
|
||||
}
|
||||
assert!(checked > 100, "expected many finite y_size_long entries");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ab_labels_reject_zero_horizon() {
|
||||
let prices = vec![100.0_f32; 10];
|
||||
let err = generate_outcome_labels_d(&prices, &[0; N_HORIZONS], 0.5);
|
||||
let err = generate_outcome_labels_ab(&prices, &[0; N_HORIZONS], 0.5);
|
||||
assert!(err.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn d_labels_reject_negative_cost() {
|
||||
fn ab_labels_reject_negative_cost() {
|
||||
let prices = vec![100.0_f32; 10];
|
||||
let err = generate_outcome_labels_d(&prices, &[3; N_HORIZONS], -0.1);
|
||||
let err = generate_outcome_labels_ab(&prices, &[3; N_HORIZONS], -0.1);
|
||||
assert!(err.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ab_labels_right_edge_is_nan() {
|
||||
let prices = vec![100.0_f32, 101.0, 102.0, 103.0, 104.0];
|
||||
let labels = generate_outcome_labels_ab(&prices, &[3; N_HORIZONS], 0.0).unwrap();
|
||||
// For K=3, valid t in [0, n-3) = [0, 2). t in {2,3,4} must be NaN prof.
|
||||
assert!(labels.y_prof_long[0][0].is_finite());
|
||||
assert!(labels.y_prof_long[0][1].is_finite());
|
||||
for t in 2..5 {
|
||||
assert!(labels.y_prof_long[0][t].is_nan());
|
||||
assert!(labels.y_prof_short[0][t].is_nan());
|
||||
assert!(labels.y_size_long[0][t].is_nan());
|
||||
assert!(labels.y_size_short[0][t].is_nan());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1011,6 +1011,14 @@ pub struct PerceptionTrainer {
|
||||
/// Threshold for directional accuracy in the lift detector (0.85
|
||||
/// per E3 + spec).
|
||||
pub aux_lift_dir_acc_threshold: f32,
|
||||
/// SDD-3 CB2: last observed per-(direction, horizon) positive-class
|
||||
/// fraction from the loader, length `2 * N_AUX_HORIZONS` laid out as
|
||||
/// `[long_h0..hN, short_h0..hN]`. Updated on every `step_batched` and
|
||||
/// available for diagnostic readback. CB5 will consume this into the
|
||||
/// BCE `pos_weight = (1 - p) / p` term inside the captured graph; CB2
|
||||
/// holds it on the host as a transition state so the data flow is
|
||||
/// already plumbed end-to-end.
|
||||
pub last_pos_fraction: Vec<f32>,
|
||||
/// `add_inplace` kernel handle — adds aux grad_x into the encoder
|
||||
/// grad buffer when stop-grad is lifted. Loaded from the
|
||||
/// `bucket_transition` cubin? Actually a dedicated `vec_add` cubin.
|
||||
@@ -2251,6 +2259,10 @@ impl PerceptionTrainer {
|
||||
// accuracy gate that proves aux is actually learning the
|
||||
// sign of the outcome (not just regressing to zero).
|
||||
aux_lift_dir_acc_threshold: 0.85,
|
||||
// CB2: stash for the per-(direction, horizon) positive-class
|
||||
// fraction; populated on every step_batched call. CB5 will
|
||||
// consume this into the BCE pos_weight term.
|
||||
last_pos_fraction: vec![0.0_f32; 2 * N_AUX_HORIZONS],
|
||||
aux_vec_add_fn,
|
||||
_aux_vec_add_module: aux_vec_add_module,
|
||||
})
|
||||
@@ -2301,17 +2313,23 @@ impl PerceptionTrainer {
|
||||
/// [`step_batched`] with batch size 1. Preserves the existing
|
||||
/// API for single-sequence callers (test smokes, etc.).
|
||||
///
|
||||
/// SDD-3 Layer B5: aux outcome labels for both directions must be
|
||||
/// supplied with the same shape as `labels_per_position`. NaN entries
|
||||
/// are natively masked by the Huber loss kernel — callers that don't
|
||||
/// have D-style outcome supervision for a particular position should
|
||||
/// pass `[f32::NAN; N_AUX_HORIZONS]` rows.
|
||||
/// SDD-3 CB2: callers pass the four A+B paired outcome arrays
|
||||
/// (prof-binary + σ-normalized size for both directions). NaN entries
|
||||
/// are natively masked by the existing Huber loss kernel — callers
|
||||
/// without aux supervision for a particular position should pass
|
||||
/// `[f32::NAN; N_AUX_HORIZONS]` rows. `pos_fraction` of length
|
||||
/// `2 * N_AUX_HORIZONS` carries the per-(direction, horizon) positive
|
||||
/// class fraction used by the CB5 BCE loss; pass zeros for the holding
|
||||
/// pattern (the Huber path ignores them).
|
||||
pub fn step(
|
||||
&mut self,
|
||||
snapshots: &[Mbp10RawInput],
|
||||
labels_per_position: &[[f32; N_HORIZONS]],
|
||||
outcome_long_per_position: &[[f32; N_AUX_HORIZONS]],
|
||||
outcome_short_per_position: &[[f32; N_AUX_HORIZONS]],
|
||||
outcome_prof_long_per_position: &[[f32; N_AUX_HORIZONS]],
|
||||
outcome_prof_short_per_position: &[[f32; N_AUX_HORIZONS]],
|
||||
outcome_size_long_per_position: &[[f32; N_AUX_HORIZONS]],
|
||||
outcome_size_short_per_position: &[[f32; N_AUX_HORIZONS]],
|
||||
pos_fraction: &[f32],
|
||||
) -> Result<f32> {
|
||||
anyhow::ensure!(
|
||||
self.cfg.n_batch == 1,
|
||||
@@ -2322,8 +2340,11 @@ impl PerceptionTrainer {
|
||||
self.step_batched(
|
||||
&[snapshots],
|
||||
&[labels_per_position],
|
||||
&[outcome_long_per_position],
|
||||
&[outcome_short_per_position],
|
||||
&[outcome_prof_long_per_position],
|
||||
&[outcome_prof_short_per_position],
|
||||
&[outcome_size_long_per_position],
|
||||
&[outcome_size_short_per_position],
|
||||
pos_fraction,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2381,12 +2402,23 @@ impl PerceptionTrainer {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// SDD-3 CB2 transition state: signature accepts the four A+B label
|
||||
/// arrays (prof-binary + σ-normalized size, both directions) plus
|
||||
/// per-file `pos_fraction`. The body still feeds the prof-binary
|
||||
/// arrays through the D-era Huber path so the workspace compiles and
|
||||
/// the trainer produces some training signal; CB5 owns the rewrite
|
||||
/// to BCE-on-prof + Huber-on-size with `pos_weight = (1-p)/p` from
|
||||
/// `pos_fraction`. The size and pos_fraction inputs are validated +
|
||||
/// accepted but not yet consumed inside the captured graph.
|
||||
pub fn step_batched(
|
||||
&mut self,
|
||||
snapshots_batch: &[&[Mbp10RawInput]],
|
||||
labels_batch: &[&[[f32; N_HORIZONS]]],
|
||||
outcome_long_batch: &[&[[f32; N_AUX_HORIZONS]]],
|
||||
outcome_short_batch: &[&[[f32; N_AUX_HORIZONS]]],
|
||||
outcome_prof_long_batch: &[&[[f32; N_AUX_HORIZONS]]],
|
||||
outcome_prof_short_batch: &[&[[f32; N_AUX_HORIZONS]]],
|
||||
outcome_size_long_batch: &[&[[f32; N_AUX_HORIZONS]]],
|
||||
outcome_size_short_batch: &[&[[f32; N_AUX_HORIZONS]]],
|
||||
pos_fraction: &[f32],
|
||||
) -> Result<f32> {
|
||||
let b_sz = self.cfg.n_batch;
|
||||
let k_seq = self.cfg.seq_len;
|
||||
@@ -2396,9 +2428,22 @@ impl PerceptionTrainer {
|
||||
b_sz, snapshots_batch.len(), labels_batch.len()
|
||||
);
|
||||
anyhow::ensure!(
|
||||
outcome_long_batch.len() == b_sz && outcome_short_batch.len() == b_sz,
|
||||
"expected B={} aux outcomes; got long={} short={}",
|
||||
b_sz, outcome_long_batch.len(), outcome_short_batch.len()
|
||||
outcome_prof_long_batch.len() == b_sz
|
||||
&& outcome_prof_short_batch.len() == b_sz
|
||||
&& outcome_size_long_batch.len() == b_sz
|
||||
&& outcome_size_short_batch.len() == b_sz,
|
||||
"expected B={} aux outcomes; got prof_long={} prof_short={} size_long={} size_short={}",
|
||||
b_sz,
|
||||
outcome_prof_long_batch.len(),
|
||||
outcome_prof_short_batch.len(),
|
||||
outcome_size_long_batch.len(),
|
||||
outcome_size_short_batch.len(),
|
||||
);
|
||||
anyhow::ensure!(
|
||||
pos_fraction.len() == 2 * N_AUX_HORIZONS,
|
||||
"pos_fraction must be length 2 * N_AUX_HORIZONS ({}); got {}",
|
||||
2 * N_AUX_HORIZONS,
|
||||
pos_fraction.len()
|
||||
);
|
||||
for (b, snap) in snapshots_batch.iter().enumerate() {
|
||||
anyhow::ensure!(
|
||||
@@ -2414,20 +2459,45 @@ impl PerceptionTrainer {
|
||||
b, lbl.len(), k_seq
|
||||
);
|
||||
}
|
||||
for (b, ol) in outcome_long_batch.iter().enumerate() {
|
||||
for (b, ol) in outcome_prof_long_batch.iter().enumerate() {
|
||||
anyhow::ensure!(
|
||||
ol.len() == k_seq,
|
||||
"batch[{}] outcome_long.len()={} != seq_len={}",
|
||||
"batch[{}] outcome_prof_long.len()={} != seq_len={}",
|
||||
b, ol.len(), k_seq
|
||||
);
|
||||
}
|
||||
for (b, os) in outcome_short_batch.iter().enumerate() {
|
||||
for (b, os) in outcome_prof_short_batch.iter().enumerate() {
|
||||
anyhow::ensure!(
|
||||
os.len() == k_seq,
|
||||
"batch[{}] outcome_short.len()={} != seq_len={}",
|
||||
"batch[{}] outcome_prof_short.len()={} != seq_len={}",
|
||||
b, os.len(), k_seq
|
||||
);
|
||||
}
|
||||
for (b, ol) in outcome_size_long_batch.iter().enumerate() {
|
||||
anyhow::ensure!(
|
||||
ol.len() == k_seq,
|
||||
"batch[{}] outcome_size_long.len()={} != seq_len={}",
|
||||
b, ol.len(), k_seq
|
||||
);
|
||||
}
|
||||
for (b, os) in outcome_size_short_batch.iter().enumerate() {
|
||||
anyhow::ensure!(
|
||||
os.len() == k_seq,
|
||||
"batch[{}] outcome_size_short.len()={} != seq_len={}",
|
||||
b, os.len(), k_seq
|
||||
);
|
||||
}
|
||||
// CB2 transition state: stash pos_fraction on the trainer for
|
||||
// diagnostic readback. CB5 will lift this into a mapped-pinned
|
||||
// upload before the BCE loss kernel records `pos_weight = (1-p)/p`
|
||||
// per (direction, horizon).
|
||||
self.last_pos_fraction.copy_from_slice(pos_fraction);
|
||||
// CB2 transition state: the size-regression targets
|
||||
// (outcome_size_{long,short}_batch) are validated above (shape
|
||||
// checks) but NOT yet routed into the captured-graph staging.
|
||||
// CB5 owns the kernel rewrite that consumes both prof + size into
|
||||
// BCE + Huber respectively and will add the matching `stg_aux_size_*`
|
||||
// mapped buffers + per-K backward slot pointers.
|
||||
|
||||
debug_assert_eq!(self.window_tensor_d.shape(), &[b_sz, k_seq, FEATURE_DIM]);
|
||||
let total_snaps = b_sz * k_seq;
|
||||
@@ -2488,10 +2558,14 @@ impl PerceptionTrainer {
|
||||
}
|
||||
}
|
||||
}
|
||||
// SDD-3 Layer B5: aux outcome staging. Same layout convention as
|
||||
// `stg_labels` ([K × B × N_AUX_HORIZONS] row-major, horizon innermost)
|
||||
// so the per-K backward slot pointers can be derived by the same
|
||||
// arithmetic as the main BCE labels.
|
||||
// SDD-3 CB2 transition state: aux outcome staging now sources the
|
||||
// prof-binary arrays (long/short) from the A+B label set. Layout is
|
||||
// unchanged ([K × B × N_AUX_HORIZONS] row-major, horizon innermost)
|
||||
// so the existing per-K backward slot pointers in the captured graph
|
||||
// still resolve. The σ-normalized size targets are routed through
|
||||
// CB5 — for now the holding-pattern D-era Huber kernel runs on the
|
||||
// prof-binary arrays which provides a finite, NaN-masked signal
|
||||
// (no convergence guarantees until CB5 lands the proper BCE+Huber).
|
||||
{
|
||||
let dst_long = self.stg_aux_y_long.host_slice_mut();
|
||||
let dst_short = self.stg_aux_y_short.host_slice_mut();
|
||||
@@ -2499,8 +2573,8 @@ impl PerceptionTrainer {
|
||||
for b_idx in 0..b_sz {
|
||||
for h in 0..N_AUX_HORIZONS {
|
||||
let idx = (k * b_sz + b_idx) * N_AUX_HORIZONS + h;
|
||||
dst_long[idx] = outcome_long_batch[b_idx][k][h];
|
||||
dst_short[idx] = outcome_short_batch[b_idx][k][h];
|
||||
dst_long[idx] = outcome_prof_long_batch[b_idx][k][h];
|
||||
dst_short[idx] = outcome_prof_short_batch[b_idx][k][h];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,28 +55,46 @@ fn synthetic_seq(
|
||||
(out, labels)
|
||||
}
|
||||
|
||||
/// SDD-3 Layer B5: synthetic aux-outcome generator matching the +0.25
|
||||
/// price ramp from `synthetic_seq`. For a constant up-trend with no
|
||||
/// drawdown, D-style labels reduce to:
|
||||
/// y_long = (K × 0.25) − cost − 0 (no long drawdown)
|
||||
/// y_short = -K × 0.25 − cost − 1.5 × K × 0.25 (full short drawup)
|
||||
/// Returns per-position rows of N_AUX_HORIZONS floats for both directions.
|
||||
/// We approximate horizon-specific values via a per-horizon multiplier;
|
||||
/// the exact value isn't critical for the constant-signal smoke — the
|
||||
/// invariant is that y_long > y_short by a non-trivial margin at every
|
||||
/// position, so the directional-accuracy gate measures correctly.
|
||||
/// SDD-3 CB2: synthetic aux-outcome generator matching the +0.25 price
|
||||
/// ramp from `synthetic_seq`. For a constant up-trend the A+B paired
|
||||
/// targets are:
|
||||
/// y_prof_long = 1.0 (every long position is profitable after 2×cost)
|
||||
/// y_prof_short = 0.0 (every short position loses)
|
||||
/// y_size_long ≈ +ratio (positive σ-normalized signed return)
|
||||
/// y_size_short = NaN (unprofitable side: size target masked)
|
||||
/// Returns five vectors of `[N_AUX_HORIZONS]` rows + the per-(dir,horizon)
|
||||
/// positive-class fraction expected by the trainer signature.
|
||||
fn synthetic_aux_outcomes(
|
||||
seq_len: usize,
|
||||
) -> (Vec<[f32; N_AUX_HORIZONS]>, Vec<[f32; N_AUX_HORIZONS]>) {
|
||||
// Per-horizon target magnitudes (in ticks). Long is positive, short
|
||||
// is negative — the trainer should learn both directions cleanly.
|
||||
let long_per_h: [f32; N_AUX_HORIZONS] = [0.5, 1.5, 5.0];
|
||||
let short_per_h: [f32; N_AUX_HORIZONS] = [-0.5, -1.5, -5.0];
|
||||
let long_row = std::array::from_fn::<f32, N_AUX_HORIZONS, _>(|h| long_per_h[h]);
|
||||
let short_row = std::array::from_fn::<f32, N_AUX_HORIZONS, _>(|h| short_per_h[h]);
|
||||
let outcome_long = vec![long_row; seq_len];
|
||||
let outcome_short = vec![short_row; seq_len];
|
||||
(outcome_long, outcome_short)
|
||||
) -> AuxOutcomes {
|
||||
let prof_long_row = std::array::from_fn::<f32, N_AUX_HORIZONS, _>(|_| 1.0);
|
||||
let prof_short_row = std::array::from_fn::<f32, N_AUX_HORIZONS, _>(|_| 0.0);
|
||||
// Per-horizon magnitude — exact values don't matter for the smoke;
|
||||
// the invariant is sign-correctness so the dir_acc gate measures.
|
||||
let size_long_per_h: [f32; N_AUX_HORIZONS] = [0.5, 1.5, 5.0];
|
||||
let size_long_row = std::array::from_fn::<f32, N_AUX_HORIZONS, _>(|h| size_long_per_h[h]);
|
||||
// Short is unprofitable → size masked with NaN (loader convention).
|
||||
let size_short_row = std::array::from_fn::<f32, N_AUX_HORIZONS, _>(|_| f32::NAN);
|
||||
// pos_fraction is 1.0 for long (all positive) and 0.0 for short.
|
||||
let mut pos_fraction = vec![0.0_f32; 2 * N_AUX_HORIZONS];
|
||||
for h in 0..N_AUX_HORIZONS { pos_fraction[h] = 1.0; }
|
||||
AuxOutcomes {
|
||||
prof_long: vec![prof_long_row; seq_len],
|
||||
prof_short: vec![prof_short_row; seq_len],
|
||||
size_long: vec![size_long_row; seq_len],
|
||||
size_short: vec![size_short_row; seq_len],
|
||||
pos_fraction,
|
||||
}
|
||||
}
|
||||
|
||||
/// CB2: bundle for the A+B paired aux-outcome targets returned by
|
||||
/// `synthetic_aux_outcomes` and consumed by `PerceptionTrainer::step`.
|
||||
struct AuxOutcomes {
|
||||
prof_long: Vec<[f32; N_AUX_HORIZONS]>,
|
||||
prof_short: Vec<[f32; N_AUX_HORIZONS]>,
|
||||
size_long: Vec<[f32; N_AUX_HORIZONS]>,
|
||||
size_short: Vec<[f32; N_AUX_HORIZONS]>,
|
||||
pos_fraction: Vec<f32>,
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -104,7 +122,7 @@ fn stacked_trainer_loss_shrinks_on_constant_signal() {
|
||||
lr_aux: 3e-3,
|
||||
};
|
||||
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
|
||||
let (out_long, out_short) = synthetic_aux_outcomes(cfg.seq_len);
|
||||
let aux = synthetic_aux_outcomes(cfg.seq_len);
|
||||
|
||||
// Initial loss over 8 batches.
|
||||
let mut initial_total = 0.0_f32;
|
||||
@@ -112,7 +130,15 @@ fn stacked_trainer_loss_shrinks_on_constant_signal() {
|
||||
let mut prev_mid = 5500.0_f32;
|
||||
for _ in 0..8 {
|
||||
let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts);
|
||||
let l = trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("step warm");
|
||||
let l = trainer.step(
|
||||
&seq,
|
||||
labels.as_slice(),
|
||||
aux.prof_long.as_slice(),
|
||||
aux.prof_short.as_slice(),
|
||||
aux.size_long.as_slice(),
|
||||
aux.size_short.as_slice(),
|
||||
&aux.pos_fraction,
|
||||
).expect("step warm");
|
||||
initial_total += l;
|
||||
prev_mid = 0.5 * (seq.last().unwrap().bid_px[0] + seq.last().unwrap().ask_px[0]);
|
||||
ts = seq.last().unwrap().ts_ns;
|
||||
@@ -125,7 +151,15 @@ fn stacked_trainer_loss_shrinks_on_constant_signal() {
|
||||
let mut window_count = 0usize;
|
||||
for step_idx in 0..250 {
|
||||
let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts);
|
||||
let l = trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("train step");
|
||||
let l = trainer.step(
|
||||
&seq,
|
||||
labels.as_slice(),
|
||||
aux.prof_long.as_slice(),
|
||||
aux.prof_short.as_slice(),
|
||||
aux.size_long.as_slice(),
|
||||
aux.size_short.as_slice(),
|
||||
&aux.pos_fraction,
|
||||
).expect("train step");
|
||||
window_loss += l;
|
||||
window_count += 1;
|
||||
if step_idx % 50 == 49 {
|
||||
@@ -145,7 +179,15 @@ fn stacked_trainer_loss_shrinks_on_constant_signal() {
|
||||
let mut final_total = 0.0_f32;
|
||||
for _ in 0..8 {
|
||||
let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts);
|
||||
let l = trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("step final");
|
||||
let l = trainer.step(
|
||||
&seq,
|
||||
labels.as_slice(),
|
||||
aux.prof_long.as_slice(),
|
||||
aux.prof_short.as_slice(),
|
||||
aux.size_long.as_slice(),
|
||||
aux.size_short.as_slice(),
|
||||
&aux.pos_fraction,
|
||||
).expect("step final");
|
||||
final_total += l;
|
||||
prev_mid = 0.5 * (seq.last().unwrap().bid_px[0] + seq.last().unwrap().ask_px[0]);
|
||||
ts = seq.last().unwrap().ts_ns;
|
||||
@@ -182,14 +224,22 @@ fn stacked_trainer_loss_shrinks_with_stride_4() {
|
||||
lr_aux: 3e-3,
|
||||
};
|
||||
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
|
||||
let (out_long, out_short) = synthetic_aux_outcomes(cfg.seq_len);
|
||||
let aux = synthetic_aux_outcomes(cfg.seq_len);
|
||||
|
||||
let mut initial = 0.0_f32;
|
||||
let mut ts = 1_000_000u64;
|
||||
let mut prev_mid = 5500.0_f32;
|
||||
for _ in 0..8 {
|
||||
let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts);
|
||||
let l = trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("step");
|
||||
let l = trainer.step(
|
||||
&seq,
|
||||
labels.as_slice(),
|
||||
aux.prof_long.as_slice(),
|
||||
aux.prof_short.as_slice(),
|
||||
aux.size_long.as_slice(),
|
||||
aux.size_short.as_slice(),
|
||||
&aux.pos_fraction,
|
||||
).expect("step");
|
||||
initial += l;
|
||||
prev_mid = 0.5 * (seq.last().unwrap().bid_px[0] + seq.last().unwrap().ask_px[0]);
|
||||
ts = seq.last().unwrap().ts_ns;
|
||||
@@ -199,7 +249,15 @@ fn stacked_trainer_loss_shrinks_with_stride_4() {
|
||||
|
||||
for _ in 0..200 {
|
||||
let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts);
|
||||
trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("step");
|
||||
trainer.step(
|
||||
&seq,
|
||||
labels.as_slice(),
|
||||
aux.prof_long.as_slice(),
|
||||
aux.prof_short.as_slice(),
|
||||
aux.size_long.as_slice(),
|
||||
aux.size_short.as_slice(),
|
||||
&aux.pos_fraction,
|
||||
).expect("step");
|
||||
prev_mid = 0.5 * (seq.last().unwrap().bid_px[0] + seq.last().unwrap().ask_px[0]);
|
||||
ts = seq.last().unwrap().ts_ns;
|
||||
}
|
||||
@@ -207,7 +265,15 @@ fn stacked_trainer_loss_shrinks_with_stride_4() {
|
||||
let mut final_loss = 0.0_f32;
|
||||
for _ in 0..8 {
|
||||
let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts);
|
||||
final_loss += trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("step");
|
||||
final_loss += trainer.step(
|
||||
&seq,
|
||||
labels.as_slice(),
|
||||
aux.prof_long.as_slice(),
|
||||
aux.prof_short.as_slice(),
|
||||
aux.size_long.as_slice(),
|
||||
aux.size_short.as_slice(),
|
||||
&aux.pos_fraction,
|
||||
).expect("step");
|
||||
prev_mid = 0.5 * (seq.last().unwrap().bid_px[0] + seq.last().unwrap().ask_px[0]);
|
||||
ts = seq.last().unwrap().ts_ns;
|
||||
}
|
||||
@@ -258,18 +324,26 @@ fn stacked_trainer_loss_shrinks_at_batch_32() {
|
||||
}
|
||||
(seqs, labels)
|
||||
};
|
||||
// SDD-3 Layer B5: synthetic aux outcomes are seq_len-determined and
|
||||
// direction-asymmetric; one shared pair of [N_HORIZONS]-rows is broadcast
|
||||
// across all B samples per step.
|
||||
let (out_long_seq, out_short_seq) = synthetic_aux_outcomes(cfg.seq_len);
|
||||
let out_long_batch: Vec<Vec<[f32; N_AUX_HORIZONS]>> =
|
||||
(0..cfg.n_batch).map(|_| out_long_seq.clone()).collect();
|
||||
let out_short_batch: Vec<Vec<[f32; N_AUX_HORIZONS]>> =
|
||||
(0..cfg.n_batch).map(|_| out_short_seq.clone()).collect();
|
||||
let out_long_refs: Vec<&[[f32; N_AUX_HORIZONS]]> =
|
||||
out_long_batch.iter().map(|v| v.as_slice()).collect();
|
||||
let out_short_refs: Vec<&[[f32; N_AUX_HORIZONS]]> =
|
||||
out_short_batch.iter().map(|v| v.as_slice()).collect();
|
||||
// SDD-3 CB2: synthetic aux outcomes are seq_len-determined and
|
||||
// direction-asymmetric; the shared per-sample rows for both prof-binary
|
||||
// and size targets are broadcast across all B samples per step.
|
||||
let aux_seq = synthetic_aux_outcomes(cfg.seq_len);
|
||||
let prof_long_batch: Vec<Vec<[f32; N_AUX_HORIZONS]>> =
|
||||
(0..cfg.n_batch).map(|_| aux_seq.prof_long.clone()).collect();
|
||||
let prof_short_batch: Vec<Vec<[f32; N_AUX_HORIZONS]>> =
|
||||
(0..cfg.n_batch).map(|_| aux_seq.prof_short.clone()).collect();
|
||||
let size_long_batch: Vec<Vec<[f32; N_AUX_HORIZONS]>> =
|
||||
(0..cfg.n_batch).map(|_| aux_seq.size_long.clone()).collect();
|
||||
let size_short_batch: Vec<Vec<[f32; N_AUX_HORIZONS]>> =
|
||||
(0..cfg.n_batch).map(|_| aux_seq.size_short.clone()).collect();
|
||||
let prof_long_refs: Vec<&[[f32; N_AUX_HORIZONS]]> =
|
||||
prof_long_batch.iter().map(|v| v.as_slice()).collect();
|
||||
let prof_short_refs: Vec<&[[f32; N_AUX_HORIZONS]]> =
|
||||
prof_short_batch.iter().map(|v| v.as_slice()).collect();
|
||||
let size_long_refs: Vec<&[[f32; N_AUX_HORIZONS]]> =
|
||||
size_long_batch.iter().map(|v| v.as_slice()).collect();
|
||||
let size_short_refs: Vec<&[[f32; N_AUX_HORIZONS]]> =
|
||||
size_short_batch.iter().map(|v| v.as_slice()).collect();
|
||||
|
||||
let mut initial = 0.0_f32;
|
||||
for warmup in 0..4 {
|
||||
@@ -277,7 +351,15 @@ fn stacked_trainer_loss_shrinks_at_batch_32() {
|
||||
let seq_refs: Vec<&[Mbp10RawInput]> = seqs.iter().map(|s| s.as_slice()).collect();
|
||||
let lbl_refs: Vec<&[[f32; N_HORIZONS]]> = labels.iter().map(|l| l.as_slice()).collect();
|
||||
let l = trainer
|
||||
.step_batched(&seq_refs, &lbl_refs, &out_long_refs, &out_short_refs)
|
||||
.step_batched(
|
||||
&seq_refs,
|
||||
&lbl_refs,
|
||||
&prof_long_refs,
|
||||
&prof_short_refs,
|
||||
&size_long_refs,
|
||||
&size_short_refs,
|
||||
&aux_seq.pos_fraction,
|
||||
)
|
||||
.expect("warm step");
|
||||
if warmup >= 2 {
|
||||
initial += l;
|
||||
@@ -291,7 +373,15 @@ fn stacked_trainer_loss_shrinks_at_batch_32() {
|
||||
let seq_refs: Vec<&[Mbp10RawInput]> = seqs.iter().map(|s| s.as_slice()).collect();
|
||||
let lbl_refs: Vec<&[[f32; N_HORIZONS]]> = labels.iter().map(|l| l.as_slice()).collect();
|
||||
trainer
|
||||
.step_batched(&seq_refs, &lbl_refs, &out_long_refs, &out_short_refs)
|
||||
.step_batched(
|
||||
&seq_refs,
|
||||
&lbl_refs,
|
||||
&prof_long_refs,
|
||||
&prof_short_refs,
|
||||
&size_long_refs,
|
||||
&size_short_refs,
|
||||
&aux_seq.pos_fraction,
|
||||
)
|
||||
.expect("train step");
|
||||
}
|
||||
|
||||
@@ -301,7 +391,15 @@ fn stacked_trainer_loss_shrinks_at_batch_32() {
|
||||
let seq_refs: Vec<&[Mbp10RawInput]> = seqs.iter().map(|s| s.as_slice()).collect();
|
||||
let lbl_refs: Vec<&[[f32; N_HORIZONS]]> = labels.iter().map(|l| l.as_slice()).collect();
|
||||
final_loss += trainer
|
||||
.step_batched(&seq_refs, &lbl_refs, &out_long_refs, &out_short_refs)
|
||||
.step_batched(
|
||||
&seq_refs,
|
||||
&lbl_refs,
|
||||
&prof_long_refs,
|
||||
&prof_short_refs,
|
||||
&size_long_refs,
|
||||
&size_short_refs,
|
||||
&aux_seq.pos_fraction,
|
||||
)
|
||||
.expect("final step");
|
||||
}
|
||||
final_loss /= 4.0;
|
||||
@@ -390,14 +488,22 @@ fn evaluate_works_after_captured_training_step() {
|
||||
lr_aux: 3e-3,
|
||||
};
|
||||
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
|
||||
let (out_long, out_short) = synthetic_aux_outcomes(cfg.seq_len);
|
||||
let aux = synthetic_aux_outcomes(cfg.seq_len);
|
||||
|
||||
// Drive enough training steps to exercise warmup → capture → replay.
|
||||
let mut ts = 1_000_000u64;
|
||||
let mut prev_mid = 5500.0_f32;
|
||||
for _ in 0..5 {
|
||||
let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts);
|
||||
trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("train step");
|
||||
trainer.step(
|
||||
&seq,
|
||||
labels.as_slice(),
|
||||
aux.prof_long.as_slice(),
|
||||
aux.prof_short.as_slice(),
|
||||
aux.size_long.as_slice(),
|
||||
aux.size_short.as_slice(),
|
||||
&aux.pos_fraction,
|
||||
).expect("train step");
|
||||
prev_mid = 0.5 * (seq.last().unwrap().bid_px[0] + seq.last().unwrap().ask_px[0]);
|
||||
ts = seq.last().unwrap().ts_ns;
|
||||
}
|
||||
@@ -429,12 +535,28 @@ fn evaluate_works_after_capture_no_replay() {
|
||||
lr_aux: 3e-3,
|
||||
};
|
||||
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
|
||||
let (out_long, out_short) = synthetic_aux_outcomes(cfg.seq_len);
|
||||
let aux = synthetic_aux_outcomes(cfg.seq_len);
|
||||
let ts = 1_000_000u64;
|
||||
let prev_mid = 5500.0_f32;
|
||||
let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts);
|
||||
trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("warmup step");
|
||||
trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("capture step");
|
||||
trainer.step(
|
||||
&seq,
|
||||
labels.as_slice(),
|
||||
aux.prof_long.as_slice(),
|
||||
aux.prof_short.as_slice(),
|
||||
aux.size_long.as_slice(),
|
||||
aux.size_short.as_slice(),
|
||||
&aux.pos_fraction,
|
||||
).expect("warmup step");
|
||||
trainer.step(
|
||||
&seq,
|
||||
labels.as_slice(),
|
||||
aux.prof_long.as_slice(),
|
||||
aux.prof_short.as_slice(),
|
||||
aux.size_long.as_slice(),
|
||||
aux.size_short.as_slice(),
|
||||
&aux.pos_fraction,
|
||||
).expect("capture step");
|
||||
// Eval right after capture, no replay.
|
||||
let (loss, _probs) = trainer.evaluate(&seq, labels.as_slice()).expect("eval after capture");
|
||||
assert!(loss.is_finite(), "eval loss must be finite, got {loss}");
|
||||
@@ -463,7 +585,7 @@ fn horizon_ema_and_lambda_track_after_training() {
|
||||
lr_aux: 3e-3,
|
||||
};
|
||||
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
|
||||
let (out_long, out_short) = synthetic_aux_outcomes(cfg.seq_len);
|
||||
let aux = synthetic_aux_outcomes(cfg.seq_len);
|
||||
|
||||
// EMA / lambda before any training step.
|
||||
let ema0 = trainer.loss_ema_snapshot().expect("ema initial");
|
||||
@@ -475,7 +597,15 @@ fn horizon_ema_and_lambda_track_after_training() {
|
||||
let mut prev_mid = 5500.0_f32;
|
||||
for _ in 0..5 {
|
||||
let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts);
|
||||
trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("train step");
|
||||
trainer.step(
|
||||
&seq,
|
||||
labels.as_slice(),
|
||||
aux.prof_long.as_slice(),
|
||||
aux.prof_short.as_slice(),
|
||||
aux.size_long.as_slice(),
|
||||
aux.size_short.as_slice(),
|
||||
&aux.pos_fraction,
|
||||
).expect("train step");
|
||||
prev_mid = 0.5 * (seq.last().unwrap().bid_px[0] + seq.last().unwrap().ask_px[0]);
|
||||
ts = seq.last().unwrap().ts_ns;
|
||||
}
|
||||
@@ -529,7 +659,7 @@ fn stacked_trainer_aux_supervision_converges_on_constant_signal() {
|
||||
lr_aux: 3e-3,
|
||||
};
|
||||
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
|
||||
let (out_long, out_short) = synthetic_aux_outcomes(cfg.seq_len);
|
||||
let aux = synthetic_aux_outcomes(cfg.seq_len);
|
||||
|
||||
let mut ts = 1_000_000u64;
|
||||
let mut prev_mid = 5500.0_f32;
|
||||
@@ -540,8 +670,11 @@ fn stacked_trainer_aux_supervision_converges_on_constant_signal() {
|
||||
.step(
|
||||
&seq,
|
||||
labels.as_slice(),
|
||||
out_long.as_slice(),
|
||||
out_short.as_slice(),
|
||||
aux.prof_long.as_slice(),
|
||||
aux.prof_short.as_slice(),
|
||||
aux.size_long.as_slice(),
|
||||
aux.size_short.as_slice(),
|
||||
&aux.pos_fraction,
|
||||
)
|
||||
.expect("train step");
|
||||
prev_mid = 0.5 * (seq.last().unwrap().bid_px[0] + seq.last().unwrap().ask_px[0]);
|
||||
@@ -605,12 +738,20 @@ fn evaluate_works_after_warmup_only() {
|
||||
lr_aux: 3e-3,
|
||||
};
|
||||
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
|
||||
let (out_long, out_short) = synthetic_aux_outcomes(cfg.seq_len);
|
||||
let aux = synthetic_aux_outcomes(cfg.seq_len);
|
||||
let ts = 1_000_000u64;
|
||||
let prev_mid = 5500.0_f32;
|
||||
let (seq, labels) = synthetic_seq(cfg.seq_len, prev_mid, ts);
|
||||
// ONLY one step — warmup, no capture yet.
|
||||
trainer.step(&seq, labels.as_slice(), out_long.as_slice(), out_short.as_slice()).expect("warmup step");
|
||||
trainer.step(
|
||||
&seq,
|
||||
labels.as_slice(),
|
||||
aux.prof_long.as_slice(),
|
||||
aux.prof_short.as_slice(),
|
||||
aux.size_long.as_slice(),
|
||||
aux.size_short.as_slice(),
|
||||
&aux.pos_fraction,
|
||||
).expect("warmup step");
|
||||
// Now eval.
|
||||
let (loss, _probs) = trainer.evaluate(&seq, labels.as_slice()).expect("eval after warmup");
|
||||
assert!(loss.is_finite(), "eval loss must be finite, got {loss}");
|
||||
|
||||
Reference in New Issue
Block a user