mirror of
https://github.com/ruvnet/RuView
synced 2026-08-03 19:21:42 +00:00
17471e93ff
* feat(calibration): NodeGeometry transceiver-geometry recording (ADR-152 §2.1.1) PerceptAlign-motivated geometry capture at enrollment: per-node optional records (position, antenna orientation, inter-node distances, acquisition method) — recorded when known, never required. Event-sourced via EnrollmentEvent::GeometryRecorded (latest recording wins); persisted on SpecialistBank with serde defaults so pre-ADR-152 bank JSON loads cleanly (fixture-proven, and geometry-free banks serialize byte-shape-identical to the old schema); threaded through MultiNodeMixture as data only — the learned geometry embeddings and algorithmic fusion use are §2.1.2, deliberately deferred until the ADR-151 P6 LoRA heads exist. Geometry recorded from now on means banks captured today remain usable for layout-conditioned training later — you can't retroactively add geometry to data you didn't record. 8 new tests (3 geometry, 2 anchor, 2 bank, 1 multistatic) + full-loop extension (2-node geometry, one tape-measured + one unknown, surviving the bank JSON round-trip the runtime loads from). 50/50 calibration (both feature configs) + 23 CLI tests green. Co-Authored-By: RuFlo <ruv@ruv.net> * feat(training): two-checkerboard camera↔room calibration for ADR-079 labels (ADR-152 §2.1.3) Defends the camera-supervised pipeline against PerceptAlign's "coordinate overfitting": MediaPipe keypoints were emitted in raw camera coordinates with no shared frame and no transceiver-geometry metadata — the exact label shape that memorizes deployment layout and collapses cross-layout. - scripts/calibrate-camera-room.py + calibration_lib.py: OpenCV two-checkerboard calibration → versioned bundle JSON (intrinsics, camera→room extrinsics, checkerboard spec, transceiver geometry, sha256 calibration_id). Intrinsics resolve from file > cache > multi-view computation > loud-warning 2-view fallback. - collect-ground-truth.py --calibration <bundle>: every sample gains keypoints_room (unit bearing rays from the camera center in the room frame — documented projective alignment; raw image coords preserved so training chooses), camera_origin_room, calibration_id, and the transceiver geometry stamp. Without the flag, output is byte-identical to before (tested) + a one-line ADR-152 warning. Design finding (recorded for ADR-152): a single planar checkerboard's corner grid is centrosymmetric — the reversed corner ordering fits a ghost camera pose with IDENTICAL reprojection error, so per-board flip disambiguation is mathematically ill-posed. solve_two_board_extrinsics solves the joint wall+floor set over all 4 flip combinations, where the minimum is unique — an independent reason the TWO-checkerboard method is required, beyond what PerceptAlign states. 15 headless pytest tests green (synthetic corners: extrinsics recovery incl. ghost resolution, bundle round-trip + hash stability, ray transforms w/ distortion + cross-resolution, no-calibration byte identity). Co-Authored-By: RuFlo <ruv@ruv.net> * feat(benchmarks): WiFlow-STD reproduction harness + measurement (a) results (ADR-152 §2.2) Shipped checkpoint REFUTED (0.08% PCK@20, wrong keypoint normalization); 6 reproducibility defects documented (broken imports, corrupted dataset tail with float32-max garbage that NaN-poisons fp16 BatchNorm, unreachable test phase). After repairs, retraining with upstream defaults reproduces 96.09% PCK@20 full-test / 96.61% corruption-free (published 97.25%) on RTX 5080. Claims graded MEASURED-EQUIVALENT; 2.23M params + ~0.055 GFLOPs verified. Third-party code/weights/data stay out of tree (gitignored). Co-Authored-By: claude-flow <ruv@ruv.net> * feat: ADR-152 Rust integrations + ADR-153 802.11bf protocol model - calibration: GeometryEmbedding — 32-slot permutation-invariant NodeGeometry featurization for future LoRA-head conditioning (ADR-152 §2.1.2); derived SpecialistBank::geometry_embedding() accessor; 59 tests - train: MaePretrainConfig + patchify/random-mask with UNSW measured recipe (80% masking, (30,3) patches; ADR-152 §2.3, arXiv 2511.18792); strict no-truncate/no-NaN policy; proptest properties - train: WiFlowStdModel — tch-gated port of the verified ~96%-PCK@20 WiFlow-STD architecture (ADR-152 §2.2 beyond-SOTA); ungated param formula pinned to 2,225,042; 15/17-keypoint support; 239 crate tests - hardware: ieee80211bf forward-compatibility protocol model (ADR-153): SpecProfile gates, SensingCapabilities negotiation, required ConsentMode, session FSM, SensingTransport + SimTransport + OpportunisticCsiBridge; full acceptance checklist covered; 156+4 tests - deps: ruvector bumps per ADR-152 §2.6 survey (mincut/solver 2.0.6, attention 2.1.0, gnn 2.2.0); vendor/ruvector synced to a083bd77f - docs: ADR-153 accepted; ADR-152 §2.2 status, §2.4 amendment, §2.6 added Workspace: 162 test suites green (--no-default-features); Python proof PASS. Known pre-existing flake: homecore-api env_empty_falls_back_to_defaults (unserialized env-var mutation) — untouched, follow-up. Co-Authored-By: claude-flow <ruv@ruv.net> * docs: CHANGELOG + CLAUDE.md entries for ADR-152 integrations and ADR-153 Co-Authored-By: claude-flow <ruv@ruv.net> * fix(train): repair tch-backend bit-rot — gated path compiles and tests run again Mechanical API refresh against current tch: Vec::from(Tensor) -> try_from (+ explicit flatten), numel() usize cast, Rem/div ops -> remainder() / divide_scalar_mode(floor) — the latter fixed a silent true-division bug in heatmap argmax decoding; clamp(1.0, f64::MAX) -> clamp_min (torch 2.x scalar overflow panic); petgraph EdgeRef import; missing EvalMetrics and verify_checkpoint_dir APIs that tests documented. wiflow_std roundtrip test uses safetensors (.pt _save_parameters roundtrip broken in torch 2.11 Windows). Gated: 349 passed (incl. all 20 wiflow_std); ungated: unchanged. Known pre-existing: gaussian-heatmap convention mismatch (2 tests), proof seed race under parallel threads — documented, deliberate follow-ups. Co-Authored-By: claude-flow <ruv@ruv.net> * feat(train): WiFlow-STD PyTorch->tch weight import + numerical parity proof export_to_safetensors.py maps the retrained checkpoint (295 tensors -> 248 mapped, param sum exactly 2,225,042; num_batches_tracked dropped) into a tch-loadable safetensors plus a deterministic parity fixture. Gated #[ignore] integration test loads it strictly and asserts forward-pass agreement: max abs diff 1.192e-7 on the seed-42 fixture. dump_variable_names test makes the tch name layout authoritative. Zero architecture discrepancies found. Co-Authored-By: claude-flow <ruv@ruv.net> * fix: workflow-review findings — BN gamma init, ThresholdParams serde, init docs Concurrent validation workflow (2 review lanes + adversarial verification, 13 agents): 5 confirmed findings, 3 refuted. Fixes: - wiflow_std: pin BatchNorm gamma to 1.0 (tch default draws Uniform(0,1) — silently halves activations in from-scratch training; loaded checkpoints unaffected, parity re-verified after the change) - wiflow_std: document the conv-init divergences vs the reference's effective kaiming_normal(fan_out) re-init (from-scratch dynamics only) - ieee80211bf: ThresholdParams deserialization validates via try_from so the <=100 invariant holds for untrusted payloads (+ rejection test) Benchmarks (release, ruvzen): GeometryEmbedding 1.84us/call (542k/s), MAE tokenization 7.38us/window (135k/s), 802.11bf FSM 8.9M events/s — nothing suspicious. Co-Authored-By: claude-flow <ruv@ruv.net> * docs(adr): ADR-152 §2.1.4 gate resolved — PerceptAlign repo MIT, dataset on HF Co-Authored-By: claude-flow <ruv@ruv.net> * feat(benchmarks): edge optimization measured + measurement (b) blocked + 92.9% retraction Edge optimization (ADR-152 optimize track): ONNX Runtime fp32 is the CPU latency win (3.2 ms/window, ~3.4x faster than torch, parity 2.4e-7); ORT dynamic int8 reaches 2.44 MB (paper's ~2.2 MB claim plausible only via conv-capable toolchains; -0.16pt PCK@20, +18% MPJPE, 2x slower); torch dynamic quant converts 0% of this conv-only model; fp16 halves storage free but is slower on CPU. Measurement (b) BLOCKED-ON-DATA: only 1,077 paired ESP32 windows exist (stop rule <2k). Forensic recheck of the surviving April holdout RETRACTS the ADR-079 '92.9% PCK@20' figure: constant-output model, absolute (not torso) threshold, 69 near-static frames — mean predictor scores 100% under that protocol; torso-PCK@20 is 19.1%. Corroborates PR #535. Stale citations removed from user-guide, readme-details, ADR-152 §2.1.3; no-citation rule extended to ADR-079 accuracy claims. Unblock: >=2k-window multi-pose paired session + torso-PCK re-baseline. Co-Authored-By: claude-flow <ruv@ruv.net> * docs(user-guide): corrected camera-supervised collection tutorial Step 0 CSI-rate check + session-length math (window yield = frames/20 — the May session's 8x under-delivery was a ~12 Hz CSI rate, not an aligner bug); two-checkerboard calibration step (ADR-152 §2.1.3); pose-variety and confidence guidance; torso-normalized PCK + temporal-split + pred-variance eval protocol (lessons from the 92.9% retraction); scale presets re-keyed to realistic window counts. Co-Authored-By: claude-flow <ruv@ruv.net> * feat(benchmarks): static PTQ int8 (calibrated) results + overnight capture script Conv-only static QDQ beats dynamic int8 on accuracy (PCK@20 96.61-96.63% vs 96.52%, MPJPE +10% vs +18% over fp32) at ~equal size/latency; all-ops QDQ strictly worse (int8 activations through attention glue). Entropy calibration verified bit-identical to MinMax on this data. Deployment: ONNX fp32 for speed (3.2ms), static conv-only QDQ for smallest (2.53MB). Also: scripts/overnight-empty-capture.py — segmented UDP CSI recorder for empty-room baselines (no glob collisions, detach-safe). Co-Authored-By: claude-flow <ruv@ruv.net> * feat(benchmarks): measurement (b) MEASURED — optimization transfer only, mean-pose baseline wins WiFlow-STD fine-tuned on 2,046 fresh single-room ESP32 paired windows (temporal 70/15/15, 70->540 adapter, K=17): pretrained-init 65% PCK@20 vs scratch 0% (optimization transfer) but frozen-trunk ~0% (no feature transfer), and NOTHING beats the mean-pose baseline (95.9% PCK@20 — single subject, near-static normalized coords). Honesty gates held: pred std 0.0113 (non-constant model) but mean-baseline dominance means no citable CSI->pose capability from this data. ADR-152 open question 1 answered partially; definitive answer needs multi-subject/position data. Two new aligner findings: heterogeneous csi_shape with silent zero-padding (~20%), and extractCsiMatrix's transposed shape label (frame-major data, [nSc, nFrames] label) — fixes pending. Co-Authored-By: claude-flow <ruv@ruv.net> * feat(benchmarks): efficiency sweep MEASURED — half model dominates full reference Compact WiFlow-STD variants on the same data/split/protocol: half (843,834 params, 0.38x) strictly dominates the 2.23M reference (PCK@20 96.62 vs 96.61, PCK@50 99.47 vs 99.11, MPJPE 0.00898 vs 0.0094) — the published architecture is over-parameterized for its own benchmark. quarter (338k) 96.05%; tiny (56,290 params, 1/39.5) holds 94.11% — a ~220KB fp32 edge candidate. In-domain caveats recorded; cross-domain untested. Co-Authored-By: claude-flow <ruv@ruv.net> * feat(train): compact WiFlow-STD presets in Rust + tiny edge artifact (ADR-152) WiFlowStdConfig gains half()/quarter()/tiny() mirroring the overnight sweep exactly: TcnGroupsMode (Fixed/Gcd/Depthwise), input_pw_groups, derived stride schedule and decoder-mid (all default to upstream behavior; legacy serde JSON unaffected). Param formulas pin to trained ground truth first try: 843,834 / 338,600 / 56,290; default 2,225,042 pin and 1.192e-7 parity unchanged. 248 tests green. Tiny edge artifact (tiny_edge_bench.py): ONNX fp32 = 295 KB, 0.66 ms/win (~1,500/s CPU), 94.11% PCK@20 (matches sweep clean-test exactly; parity 1.49e-7). Static int8 is a bad trade at this scale (-1.43pt, +19% MPJPE, -16% size, slower) — recorded as negative result. Export note: width-16 breaks AdaptiveAvgPool((15,1)) TorchScript export; replaced by exact mean+matmul equivalent, proven by parity. Co-Authored-By: claude-flow <ruv@ruv.net> * fix: resolve all 10 confirmed code-review findings (7-angle review, 20/20 verified) wiflow_std: min_feature_width (default 15) replaces the keypoints->stride coupling — for_keypoints(17) now provably builds the trained [2,2,2,2] graph and pools 15->17, matching the validated Python protocol (pinned by tests); param_count() total on invalid configs; random_mask returns Result and rejects non-finite/out-of-range ratios; trainer checkpoints switched to safetensors (.pt VarStore roundtrip broken on Windows torch 2.11). ieee80211bf: SBP proxy now re-triggers instances and relays reports via Action::RelaySbpReport -> SensingFrame::SbpReport (clients consume via their existing path); missed_instances reset on success = consecutive semantics; SessionTable gains a guarded SBP entry point + unknown-id drop counter; initiator-role sessions reject inbound setup/SBP requests (RejectedNotSupported) closing the idle hijack; StartSetup/StartSbp outside Idle return InvalidStateForCommand; SBP validation unified through evaluate_setup with a 1:1 SetupStatus->SbpStatus mapping. events.rs split out to honor the 500-line cap. calibration/cli: enrollment geometry now actually reaches trained banks — both production call sites attach .with_geometry; --geometry flag on train-room and POST /enroll/geometry + train-body geometry on calibrate-serve give production a recording surface; geometry-free banks log the ADR-152 §2.1.2 note. benchmarks: corruption masks committed as ground truth (unregenerable after in-place cleaning; verified bit-identical regeneration from the pristine copy) + generate_corruption_masks.py producer; _bench_common.py dedups the 5x-copied shim/evaluate/seed/remap (post-refactor PCK@20 re-verified equal to the last digit); remote scripts get the mmap patch; tiny_edge --calib validated multiple-of-64; onnx_bench --help no longer executes (and overwrote) the export — artifact restored byte-exact. Workspace: 2,963 tests passed, 0 failed; Python proof PASS. Co-Authored-By: claude-flow <ruv@ruv.net> * ci: build workspace tests without debuginfo — runner disk exhaustion The combined 38-crate debug target exceeds the GitHub runner's disk ('final link failed: No space left on device'); the same tree measured 151GB locally with full debuginfo. CARGO_PROFILE_{DEV,TEST}_DEBUG=0 shrinks the target ~5-10x; debuginfo serves no purpose in CI test runs. Co-Authored-By: claude-flow <ruv@ruv.net>
801 lines
28 KiB
Rust
801 lines
28 KiB
Rust
//! Training loop for WiFi-DensePose.
|
||
//!
|
||
//! # Features
|
||
//!
|
||
//! - Mini-batch training with [`DataLoader`]-style iteration
|
||
//! - Validation every N epochs with PCK\@0.2 and OKS metrics
|
||
//! - Best-checkpoint saving (by validation PCK)
|
||
//! - CSV logging (`epoch, train_loss, val_pck, val_oks, lr`)
|
||
//! - Gradient clipping
|
||
//! - LR scheduling (step decay at configured milestones)
|
||
//! - Early stopping
|
||
//!
|
||
//! # No mock data
|
||
//!
|
||
//! The trainer never generates random or synthetic data. It operates
|
||
//! exclusively on the [`CsiDataset`] passed at call site. The
|
||
//! [`SyntheticCsiDataset`] is only used for the deterministic proof protocol.
|
||
|
||
use std::io::Write as IoWrite;
|
||
use std::path::{Path, PathBuf};
|
||
use std::time::Instant;
|
||
|
||
use ndarray::{Array1, Array2};
|
||
use tch::{nn, nn::OptimizerConfig, Device, Kind, Tensor};
|
||
use tracing::{debug, info, warn};
|
||
|
||
use crate::config::TrainingConfig;
|
||
use crate::dataset::{CsiDataset, CsiSample};
|
||
use crate::error::TrainError;
|
||
use crate::losses::generate_target_heatmaps;
|
||
use crate::losses::{LossWeights, WiFiDensePoseLoss};
|
||
use crate::metrics::{MetricsAccumulator, MetricsResult};
|
||
use crate::model::WiFiDensePoseModel;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Public result types
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Per-epoch training log entry.
|
||
#[derive(Debug, Clone)]
|
||
pub struct EpochLog {
|
||
/// Epoch number (1-indexed).
|
||
pub epoch: usize,
|
||
/// Mean total loss over all training batches.
|
||
pub train_loss: f64,
|
||
/// Mean keypoint-only loss component.
|
||
pub train_kp_loss: f64,
|
||
/// Validation PCK\@0.2 (0–1). `0.0` when validation was skipped.
|
||
pub val_pck: f32,
|
||
/// Validation OKS (0–1). `0.0` when validation was skipped.
|
||
pub val_oks: f32,
|
||
/// Learning rate at the end of this epoch.
|
||
pub lr: f64,
|
||
/// Wall-clock duration of this epoch in seconds.
|
||
pub duration_secs: f64,
|
||
}
|
||
|
||
/// Summary returned after a completed (or early-stopped) training run.
|
||
#[derive(Debug, Clone)]
|
||
pub struct TrainResult {
|
||
/// Best validation PCK achieved during training.
|
||
pub best_pck: f32,
|
||
/// Epoch at which `best_pck` was achieved (1-indexed).
|
||
pub best_epoch: usize,
|
||
/// Training loss on the last completed epoch.
|
||
pub final_train_loss: f64,
|
||
/// Full per-epoch log.
|
||
pub training_history: Vec<EpochLog>,
|
||
/// Path to the best checkpoint file, if any was saved.
|
||
pub checkpoint_path: Option<PathBuf>,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Trainer
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Orchestrates the full WiFi-DensePose training pipeline.
|
||
///
|
||
/// Create via [`Trainer::new`], then call [`Trainer::train`] with real dataset
|
||
/// references.
|
||
pub struct Trainer {
|
||
config: TrainingConfig,
|
||
model: WiFiDensePoseModel,
|
||
device: Device,
|
||
}
|
||
|
||
impl Trainer {
|
||
/// Create a new `Trainer` from the given configuration.
|
||
///
|
||
/// The model and device are initialised from `config`.
|
||
pub fn new(config: TrainingConfig) -> Self {
|
||
let device = if config.use_gpu {
|
||
Device::Cuda(config.gpu_device_id as usize)
|
||
} else {
|
||
Device::Cpu
|
||
};
|
||
|
||
tch::manual_seed(config.seed as i64);
|
||
|
||
let model = WiFiDensePoseModel::new(&config, device);
|
||
Trainer {
|
||
config,
|
||
model,
|
||
device,
|
||
}
|
||
}
|
||
|
||
/// Run the full training loop.
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// - [`TrainError::EmptyDataset`] if either dataset is empty.
|
||
/// - [`TrainError::TrainingStep`] on unrecoverable forward/backward errors.
|
||
/// - [`TrainError::Checkpoint`] if writing checkpoints fails.
|
||
pub fn train(
|
||
&mut self,
|
||
train_dataset: &dyn CsiDataset,
|
||
val_dataset: &dyn CsiDataset,
|
||
) -> Result<TrainResult, TrainError> {
|
||
if train_dataset.is_empty() {
|
||
return Err(TrainError::EmptyDataset);
|
||
}
|
||
if val_dataset.is_empty() {
|
||
return Err(TrainError::EmptyDataset);
|
||
}
|
||
|
||
// Prepare output directories.
|
||
std::fs::create_dir_all(&self.config.checkpoint_dir)
|
||
.map_err(|e| TrainError::training_step(format!("create checkpoint dir: {e}")))?;
|
||
std::fs::create_dir_all(&self.config.log_dir)
|
||
.map_err(|e| TrainError::training_step(format!("create log dir: {e}")))?;
|
||
|
||
// Build optimizer (AdamW).
|
||
let mut opt = nn::AdamW::default()
|
||
.wd(self.config.weight_decay)
|
||
.build(self.model.var_store_mut(), self.config.learning_rate)
|
||
.map_err(|e| TrainError::training_step(e.to_string()))?;
|
||
|
||
let loss_fn = WiFiDensePoseLoss::new(LossWeights {
|
||
lambda_kp: self.config.lambda_kp,
|
||
lambda_dp: self.config.lambda_dp,
|
||
lambda_tr: self.config.lambda_tr,
|
||
});
|
||
|
||
// CSV log file.
|
||
let csv_path = self.config.log_dir.join("training_log.csv");
|
||
let mut csv_file = std::fs::OpenOptions::new()
|
||
.write(true)
|
||
.create(true)
|
||
.truncate(true)
|
||
.open(&csv_path)
|
||
.map_err(|e| TrainError::training_step(format!("open csv log: {e}")))?;
|
||
writeln!(
|
||
csv_file,
|
||
"epoch,train_loss,train_kp_loss,val_pck,val_oks,lr,duration_secs"
|
||
)
|
||
.map_err(|e| TrainError::training_step(format!("write csv header: {e}")))?;
|
||
|
||
let mut training_history: Vec<EpochLog> = Vec::new();
|
||
let mut best_pck: f32 = -1.0;
|
||
let mut best_epoch: usize = 0;
|
||
let mut best_checkpoint_path: Option<PathBuf> = None;
|
||
|
||
// Early-stopping state: track the last N val_pck values.
|
||
let patience = self.config.early_stopping_patience;
|
||
let mut patience_counter: usize = 0;
|
||
let min_delta = 1e-4_f32;
|
||
|
||
let mut current_lr = self.config.learning_rate;
|
||
|
||
info!(
|
||
"Training {} for {} epochs on '{}' → '{}'",
|
||
train_dataset.name(),
|
||
self.config.num_epochs,
|
||
train_dataset.name(),
|
||
val_dataset.name()
|
||
);
|
||
|
||
for epoch in 1..=self.config.num_epochs {
|
||
let epoch_start = Instant::now();
|
||
|
||
// ── LR scheduling ──────────────────────────────────────────────
|
||
if self.config.lr_milestones.contains(&epoch) {
|
||
current_lr *= self.config.lr_gamma;
|
||
opt.set_lr(current_lr);
|
||
info!("Epoch {epoch}: LR decayed to {current_lr:.2e}");
|
||
}
|
||
|
||
// ── Warmup ─────────────────────────────────────────────────────
|
||
if epoch <= self.config.warmup_epochs {
|
||
let warmup_lr =
|
||
self.config.learning_rate * epoch as f64 / self.config.warmup_epochs as f64;
|
||
opt.set_lr(warmup_lr);
|
||
current_lr = warmup_lr;
|
||
}
|
||
|
||
// ── Training batches ───────────────────────────────────────────
|
||
// Deterministic shuffle: seed = config.seed XOR epoch.
|
||
let shuffle_seed = self.config.seed ^ (epoch as u64);
|
||
let batches = make_batches(
|
||
train_dataset,
|
||
self.config.batch_size,
|
||
true,
|
||
shuffle_seed,
|
||
self.device,
|
||
);
|
||
|
||
let mut total_loss_sum = 0.0_f64;
|
||
let mut kp_loss_sum = 0.0_f64;
|
||
let mut n_batches = 0_usize;
|
||
|
||
for (amp_batch, phase_batch, kp_batch, vis_batch) in &batches {
|
||
let output = self.model.forward_train(amp_batch, phase_batch);
|
||
|
||
// Build target heatmaps from ground-truth keypoints.
|
||
let target_hm = kp_to_heatmap_tensor(
|
||
kp_batch,
|
||
vis_batch,
|
||
self.config.heatmap_size,
|
||
self.device,
|
||
);
|
||
|
||
// Binary visibility mask [B, 17].
|
||
let vis_mask = (vis_batch.gt(0.0)).to_kind(Kind::Float);
|
||
|
||
// Compute keypoint loss only (no DensePose GT in this pipeline).
|
||
let (total_tensor, loss_out) = loss_fn.forward(
|
||
&output.keypoints,
|
||
&target_hm,
|
||
&vis_mask,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
);
|
||
|
||
opt.zero_grad();
|
||
total_tensor.backward();
|
||
opt.clip_grad_norm(self.config.grad_clip_norm);
|
||
opt.step();
|
||
|
||
total_loss_sum += loss_out.total as f64;
|
||
kp_loss_sum += loss_out.keypoint as f64;
|
||
n_batches += 1;
|
||
|
||
debug!(
|
||
"Epoch {epoch} batch {n_batches}: loss={:.4}",
|
||
loss_out.total
|
||
);
|
||
}
|
||
|
||
let mean_loss = if n_batches > 0 {
|
||
total_loss_sum / n_batches as f64
|
||
} else {
|
||
0.0
|
||
};
|
||
let mean_kp_loss = if n_batches > 0 {
|
||
kp_loss_sum / n_batches as f64
|
||
} else {
|
||
0.0
|
||
};
|
||
|
||
// ── Validation ─────────────────────────────────────────────────
|
||
let mut val_pck = 0.0_f32;
|
||
let mut val_oks = 0.0_f32;
|
||
|
||
if epoch % self.config.val_every_epochs == 0 {
|
||
match self.evaluate(val_dataset) {
|
||
Ok(metrics) => {
|
||
val_pck = metrics.pck;
|
||
val_oks = metrics.oks;
|
||
info!(
|
||
"Epoch {epoch}: loss={mean_loss:.4} val_pck={val_pck:.4} val_oks={val_oks:.4} lr={current_lr:.2e}"
|
||
);
|
||
}
|
||
Err(e) => {
|
||
warn!("Validation failed at epoch {epoch}: {e}");
|
||
}
|
||
}
|
||
|
||
// ── Checkpoint saving ──────────────────────────────────────
|
||
if val_pck > best_pck + min_delta {
|
||
best_pck = val_pck;
|
||
best_epoch = epoch;
|
||
patience_counter = 0;
|
||
|
||
// .safetensors, not .pt: VarStore dispatches the format on
|
||
// the extension, and this torch build's .pt
|
||
// _save_parameters/_load_parameters roundtrip is broken on
|
||
// Windows (torch 2.11 GenericDict internal assert — see
|
||
// wiflow_std/model.rs save_and_load_roundtrip).
|
||
let ckpt_name = format!("best_epoch{epoch:04}_pck{val_pck:.4}.safetensors");
|
||
let ckpt_path = self.config.checkpoint_dir.join(&ckpt_name);
|
||
|
||
match self.model.save(&ckpt_path) {
|
||
Ok(_) => {
|
||
info!("Saved best checkpoint: {}", ckpt_path.display());
|
||
best_checkpoint_path = Some(ckpt_path);
|
||
}
|
||
Err(e) => {
|
||
warn!("Failed to save checkpoint: {e}");
|
||
}
|
||
}
|
||
} else {
|
||
patience_counter += 1;
|
||
}
|
||
}
|
||
|
||
let epoch_secs = epoch_start.elapsed().as_secs_f64();
|
||
let log = EpochLog {
|
||
epoch,
|
||
train_loss: mean_loss,
|
||
train_kp_loss: mean_kp_loss,
|
||
val_pck,
|
||
val_oks,
|
||
lr: current_lr,
|
||
duration_secs: epoch_secs,
|
||
};
|
||
|
||
// Write CSV row.
|
||
writeln!(
|
||
csv_file,
|
||
"{},{:.6},{:.6},{:.6},{:.6},{:.2e},{:.3}",
|
||
log.epoch,
|
||
log.train_loss,
|
||
log.train_kp_loss,
|
||
log.val_pck,
|
||
log.val_oks,
|
||
log.lr,
|
||
log.duration_secs,
|
||
)
|
||
.map_err(|e| TrainError::training_step(format!("write csv row: {e}")))?;
|
||
|
||
training_history.push(log);
|
||
|
||
// ── Early stopping check ───────────────────────────────────────
|
||
if patience_counter >= patience {
|
||
info!(
|
||
"Early stopping at epoch {epoch}: no improvement for {patience} validation rounds."
|
||
);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Save final model regardless (.safetensors — see checkpoint note above).
|
||
let final_ckpt = self.config.checkpoint_dir.join("final.safetensors");
|
||
if let Err(e) = self.model.save(&final_ckpt) {
|
||
warn!("Failed to save final model: {e}");
|
||
}
|
||
|
||
Ok(TrainResult {
|
||
best_pck: best_pck.max(0.0),
|
||
best_epoch,
|
||
final_train_loss: training_history.last().map(|l| l.train_loss).unwrap_or(0.0),
|
||
training_history,
|
||
checkpoint_path: best_checkpoint_path,
|
||
})
|
||
}
|
||
|
||
/// Evaluate on a dataset, returning PCK and OKS metrics.
|
||
///
|
||
/// Runs inference (no gradient) over the full dataset using the configured
|
||
/// batch size.
|
||
pub fn evaluate(&self, dataset: &dyn CsiDataset) -> Result<MetricsResult, TrainError> {
|
||
if dataset.is_empty() {
|
||
return Err(TrainError::EmptyDataset);
|
||
}
|
||
|
||
let mut acc = MetricsAccumulator::default_threshold();
|
||
|
||
let batches = make_batches(
|
||
dataset,
|
||
self.config.batch_size,
|
||
false, // no shuffle during evaluation
|
||
self.config.seed,
|
||
self.device,
|
||
);
|
||
|
||
for (amp_batch, phase_batch, kp_batch, vis_batch) in &batches {
|
||
let output = self.model.forward_inference(amp_batch, phase_batch);
|
||
|
||
// Extract predicted keypoints from heatmaps.
|
||
// Strategy: argmax over spatial dimensions → (x, y).
|
||
let pred_kps = heatmap_to_keypoints(&output.keypoints);
|
||
|
||
// Convert GT tensors back to ndarray for MetricsAccumulator.
|
||
let batch_size = kp_batch.size()[0] as usize;
|
||
for b in 0..batch_size {
|
||
let pred_kp_np = extract_kp_ndarray(&pred_kps, b);
|
||
let gt_kp_np = extract_kp_ndarray(kp_batch, b);
|
||
let vis_np = extract_vis_ndarray(vis_batch, b);
|
||
|
||
acc.update(&pred_kp_np, >_kp_np, &vis_np);
|
||
}
|
||
}
|
||
|
||
acc.finalize().ok_or(TrainError::EmptyDataset)
|
||
}
|
||
|
||
/// Save a training checkpoint.
|
||
pub fn save_checkpoint(
|
||
&self,
|
||
path: &Path,
|
||
_epoch: usize,
|
||
_metrics: &MetricsResult,
|
||
) -> Result<(), TrainError> {
|
||
self.model.save(path)
|
||
}
|
||
|
||
/// Load model weights from a checkpoint.
|
||
///
|
||
/// Returns the epoch number encoded in the filename (if any), or `0`.
|
||
pub fn load_checkpoint(&mut self, path: &Path) -> Result<usize, TrainError> {
|
||
self.model
|
||
.var_store_mut()
|
||
.load(path)
|
||
.map_err(|e| TrainError::checkpoint(e.to_string(), path))?;
|
||
|
||
// Try to parse the epoch from the filename, extension-agnostic
|
||
// (e.g. "best_epoch0042_pck0.7842.safetensors").
|
||
let epoch = path
|
||
.file_stem()
|
||
.and_then(|s| s.to_str())
|
||
.and_then(|s| {
|
||
s.split("epoch")
|
||
.nth(1)
|
||
.and_then(|rest| rest.split('_').next())
|
||
.and_then(|n| n.parse::<usize>().ok())
|
||
})
|
||
.unwrap_or(0);
|
||
|
||
Ok(epoch)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Batch construction helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Build all training batches for one epoch.
|
||
///
|
||
/// `shuffle=true` uses a deterministic LCG permutation seeded with `seed`.
|
||
/// This guarantees reproducibility: same seed → same iteration order, with
|
||
/// no dependence on OS entropy.
|
||
pub fn make_batches(
|
||
dataset: &dyn CsiDataset,
|
||
batch_size: usize,
|
||
shuffle: bool,
|
||
seed: u64,
|
||
device: Device,
|
||
) -> Vec<(Tensor, Tensor, Tensor, Tensor)> {
|
||
let n = dataset.len();
|
||
if n == 0 {
|
||
return vec![];
|
||
}
|
||
|
||
// Build index permutation (or identity).
|
||
let mut indices: Vec<usize> = (0..n).collect();
|
||
if shuffle {
|
||
lcg_shuffle(&mut indices, seed);
|
||
}
|
||
|
||
// Partition into batches.
|
||
let mut batches = Vec::new();
|
||
let mut cursor = 0;
|
||
while cursor < indices.len() {
|
||
let end = (cursor + batch_size).min(indices.len());
|
||
let batch_indices = &indices[cursor..end];
|
||
|
||
// Load samples.
|
||
let mut samples: Vec<CsiSample> = Vec::with_capacity(batch_indices.len());
|
||
for &idx in batch_indices {
|
||
match dataset.get(idx) {
|
||
Ok(s) => samples.push(s),
|
||
Err(e) => {
|
||
warn!("Skipping sample {idx}: {e}");
|
||
}
|
||
}
|
||
}
|
||
|
||
if !samples.is_empty() {
|
||
let batch = collate(&samples, device);
|
||
batches.push(batch);
|
||
}
|
||
|
||
cursor = end;
|
||
}
|
||
|
||
batches
|
||
}
|
||
|
||
/// Deterministic Fisher-Yates shuffle using a Linear Congruential Generator.
|
||
///
|
||
/// LCG parameters: multiplier = 6364136223846793005,
|
||
/// increment = 1442695040888963407 (Knuth's MMIX)
|
||
fn lcg_shuffle(indices: &mut [usize], seed: u64) {
|
||
let n = indices.len();
|
||
if n <= 1 {
|
||
return;
|
||
}
|
||
|
||
let mut state = seed.wrapping_add(1); // avoid seed=0 degeneracy
|
||
let mul: u64 = 6364136223846793005;
|
||
let inc: u64 = 1442695040888963407;
|
||
|
||
for i in (1..n).rev() {
|
||
state = state.wrapping_mul(mul).wrapping_add(inc);
|
||
let j = (state >> 33) as usize % (i + 1);
|
||
indices.swap(i, j);
|
||
}
|
||
}
|
||
|
||
/// Collate a slice of [`CsiSample`]s into four batched tensors.
|
||
///
|
||
/// Returns `(amplitude, phase, keypoints, visibility)`:
|
||
/// - `amplitude`: `[B, T*n_tx*n_rx, n_sub]`
|
||
/// - `phase`: `[B, T*n_tx*n_rx, n_sub]`
|
||
/// - `keypoints`: `[B, 17, 2]`
|
||
/// - `visibility`: `[B, 17]`
|
||
pub fn collate(samples: &[CsiSample], device: Device) -> (Tensor, Tensor, Tensor, Tensor) {
|
||
let b = samples.len();
|
||
assert!(b > 0, "collate requires at least one sample");
|
||
|
||
let s0 = &samples[0];
|
||
let shape = s0.amplitude.shape();
|
||
let (t, n_tx, n_rx, n_sub) = (shape[0], shape[1], shape[2], shape[3]);
|
||
let flat_ant = t * n_tx * n_rx;
|
||
let num_kp = s0.keypoints.shape()[0];
|
||
|
||
// Allocate host buffers.
|
||
let mut amp_data = vec![0.0_f32; b * flat_ant * n_sub];
|
||
let mut ph_data = vec![0.0_f32; b * flat_ant * n_sub];
|
||
let mut kp_data = vec![0.0_f32; b * num_kp * 2];
|
||
let mut vis_data = vec![0.0_f32; b * num_kp];
|
||
|
||
for (bi, sample) in samples.iter().enumerate() {
|
||
// Amplitude: [T, n_tx, n_rx, n_sub] → flatten to [T*n_tx*n_rx, n_sub]
|
||
let amp_flat: Vec<f32> = sample.amplitude.iter().copied().collect();
|
||
let ph_flat: Vec<f32> = sample.phase.iter().copied().collect();
|
||
|
||
let stride = flat_ant * n_sub;
|
||
amp_data[bi * stride..(bi + 1) * stride].copy_from_slice(&_flat);
|
||
ph_data[bi * stride..(bi + 1) * stride].copy_from_slice(&ph_flat);
|
||
|
||
// Keypoints.
|
||
let kp_stride = num_kp * 2;
|
||
for j in 0..num_kp {
|
||
kp_data[bi * kp_stride + j * 2] = sample.keypoints[[j, 0]];
|
||
kp_data[bi * kp_stride + j * 2 + 1] = sample.keypoints[[j, 1]];
|
||
vis_data[bi * num_kp + j] = sample.keypoint_visibility[j];
|
||
}
|
||
}
|
||
|
||
let amp_t = Tensor::from_slice(&_data)
|
||
.reshape([b as i64, flat_ant as i64, n_sub as i64])
|
||
.to_device(device);
|
||
let ph_t = Tensor::from_slice(&ph_data)
|
||
.reshape([b as i64, flat_ant as i64, n_sub as i64])
|
||
.to_device(device);
|
||
let kp_t = Tensor::from_slice(&kp_data)
|
||
.reshape([b as i64, num_kp as i64, 2])
|
||
.to_device(device);
|
||
let vis_t = Tensor::from_slice(&vis_data)
|
||
.reshape([b as i64, num_kp as i64])
|
||
.to_device(device);
|
||
|
||
(amp_t, ph_t, kp_t, vis_t)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Heatmap utilities
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Convert ground-truth keypoints to Gaussian target heatmaps.
|
||
///
|
||
/// Wraps [`generate_target_heatmaps`] to work on `tch::Tensor` inputs.
|
||
fn kp_to_heatmap_tensor(
|
||
kp_tensor: &Tensor,
|
||
vis_tensor: &Tensor,
|
||
heatmap_size: usize,
|
||
device: Device,
|
||
) -> Tensor {
|
||
// kp_tensor: [B, 17, 2]
|
||
// vis_tensor: [B, 17]
|
||
let b = kp_tensor.size()[0] as usize;
|
||
let num_kp = kp_tensor.size()[1] as usize;
|
||
|
||
// Convert to ndarray for generate_target_heatmaps.
|
||
let kp_vec: Vec<f32> = Vec::<f64>::try_from(kp_tensor.to_kind(Kind::Double).flatten(0, -1))
|
||
.expect("kp tensor to vec")
|
||
.iter()
|
||
.map(|&x| x as f32)
|
||
.collect();
|
||
let vis_vec: Vec<f32> = Vec::<f64>::try_from(vis_tensor.to_kind(Kind::Double).flatten(0, -1))
|
||
.expect("vis tensor to vec")
|
||
.iter()
|
||
.map(|&x| x as f32)
|
||
.collect();
|
||
|
||
let kp_nd = ndarray::Array3::from_shape_vec((b, num_kp, 2), kp_vec).expect("kp shape");
|
||
let vis_nd = ndarray::Array2::from_shape_vec((b, num_kp), vis_vec).expect("vis shape");
|
||
|
||
let hm_nd = generate_target_heatmaps(&kp_nd, &vis_nd, heatmap_size, 2.0);
|
||
|
||
// [B, 17, H, W]
|
||
let flat: Vec<f32> = hm_nd.iter().copied().collect();
|
||
Tensor::from_slice(&flat)
|
||
.reshape([
|
||
b as i64,
|
||
num_kp as i64,
|
||
heatmap_size as i64,
|
||
heatmap_size as i64,
|
||
])
|
||
.to_device(device)
|
||
}
|
||
|
||
/// Convert predicted heatmaps to normalised keypoint coordinates via argmax.
|
||
///
|
||
/// Input: `[B, 17, H, W]`
|
||
/// Output: `[B, 17, 2]` with (x, y) in [0, 1]
|
||
fn heatmap_to_keypoints(heatmaps: &Tensor) -> Tensor {
|
||
let sizes = heatmaps.size();
|
||
let (batch, num_kp, h, w) = (sizes[0], sizes[1], sizes[2], sizes[3]);
|
||
|
||
// Flatten spatial → [B, 17, H*W]
|
||
let flat = heatmaps.reshape([batch, num_kp, h * w]);
|
||
// Argmax per joint → [B, 17]
|
||
let arg = flat.argmax(-1, false);
|
||
|
||
// Decompose linear index into (row, col).
|
||
let row = arg.divide_scalar_mode(w, "floor").to_kind(Kind::Float); // [B, 17]
|
||
let col = arg.remainder(w).to_kind(Kind::Float); // [B, 17]
|
||
|
||
// Normalize to [0, 1]
|
||
let x = col / (w - 1) as f64;
|
||
let y = row / (h - 1) as f64;
|
||
|
||
// Stack to [B, 17, 2]
|
||
Tensor::stack(&[x, y], -1)
|
||
}
|
||
|
||
/// Extract a single sample's keypoints as an ndarray from a batched tensor.
|
||
///
|
||
/// `kp_tensor` shape: `[B, 17, 2]`
|
||
fn extract_kp_ndarray(kp_tensor: &Tensor, batch_idx: usize) -> Array2<f32> {
|
||
let num_kp = kp_tensor.size()[1] as usize;
|
||
let row = kp_tensor.select(0, batch_idx as i64);
|
||
let data: Vec<f32> = Vec::<f64>::try_from(row.to_kind(Kind::Double).flatten(0, -1))
|
||
.expect("kp tensor to vec")
|
||
.iter()
|
||
.map(|&v| v as f32)
|
||
.collect();
|
||
Array2::from_shape_vec((num_kp, 2), data).expect("kp ndarray shape")
|
||
}
|
||
|
||
/// Extract a single sample's visibility flags as an ndarray from a batched tensor.
|
||
///
|
||
/// `vis_tensor` shape: `[B, 17]`
|
||
fn extract_vis_ndarray(vis_tensor: &Tensor, batch_idx: usize) -> Array1<f32> {
|
||
let num_kp = vis_tensor.size()[1] as usize;
|
||
let row = vis_tensor.select(0, batch_idx as i64);
|
||
let data: Vec<f32> = Vec::<f64>::try_from(row.to_kind(Kind::Double))
|
||
.expect("vis tensor to vec")
|
||
.iter()
|
||
.map(|&v| v as f32)
|
||
.collect();
|
||
Array1::from_vec(data)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::config::TrainingConfig;
|
||
use crate::dataset::{SyntheticConfig, SyntheticCsiDataset};
|
||
|
||
fn tiny_config() -> TrainingConfig {
|
||
let mut cfg = TrainingConfig::default();
|
||
cfg.num_subcarriers = 8;
|
||
cfg.window_frames = 2;
|
||
cfg.num_antennas_tx = 1;
|
||
cfg.num_antennas_rx = 1;
|
||
cfg.heatmap_size = 8;
|
||
cfg.backbone_channels = 32;
|
||
cfg.num_epochs = 2;
|
||
cfg.warmup_epochs = 1;
|
||
cfg.batch_size = 4;
|
||
cfg.val_every_epochs = 1;
|
||
cfg.early_stopping_patience = 5;
|
||
cfg.lr_milestones = vec![2];
|
||
cfg
|
||
}
|
||
|
||
fn tiny_synthetic_dataset(n: usize) -> SyntheticCsiDataset {
|
||
let cfg = tiny_config();
|
||
SyntheticCsiDataset::new(
|
||
n,
|
||
SyntheticConfig {
|
||
num_subcarriers: cfg.num_subcarriers,
|
||
num_antennas_tx: cfg.num_antennas_tx,
|
||
num_antennas_rx: cfg.num_antennas_rx,
|
||
window_frames: cfg.window_frames,
|
||
num_keypoints: 17,
|
||
signal_frequency_hz: 2.4e9,
|
||
},
|
||
)
|
||
}
|
||
|
||
#[test]
|
||
fn collate_produces_correct_shapes() {
|
||
let ds = tiny_synthetic_dataset(4);
|
||
let samples: Vec<_> = (0..4).map(|i| ds.get(i).unwrap()).collect();
|
||
let (amp, ph, kp, vis) = collate(&samples, Device::Cpu);
|
||
|
||
let cfg = tiny_config();
|
||
let flat_ant = (cfg.window_frames * cfg.num_antennas_tx * cfg.num_antennas_rx) as i64;
|
||
assert_eq!(amp.size(), [4, flat_ant, cfg.num_subcarriers as i64]);
|
||
assert_eq!(ph.size(), [4, flat_ant, cfg.num_subcarriers as i64]);
|
||
assert_eq!(kp.size(), [4, 17, 2]);
|
||
assert_eq!(vis.size(), [4, 17]);
|
||
}
|
||
|
||
#[test]
|
||
fn make_batches_covers_all_samples() {
|
||
let ds = tiny_synthetic_dataset(10);
|
||
let batches = make_batches(&ds, 3, false, 42, Device::Cpu);
|
||
let total: i64 = batches.iter().map(|(a, _, _, _)| a.size()[0]).sum();
|
||
assert_eq!(total, 10);
|
||
}
|
||
|
||
#[test]
|
||
fn make_batches_shuffle_reproducible() {
|
||
let ds = tiny_synthetic_dataset(10);
|
||
let b1 = make_batches(&ds, 3, true, 99, Device::Cpu);
|
||
let b2 = make_batches(&ds, 3, true, 99, Device::Cpu);
|
||
// Shapes should match exactly.
|
||
for (batch_a, batch_b) in b1.iter().zip(b2.iter()) {
|
||
assert_eq!(batch_a.0.size(), batch_b.0.size());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn lcg_shuffle_is_permutation() {
|
||
let mut idx: Vec<usize> = (0..20).collect();
|
||
lcg_shuffle(&mut idx, 42);
|
||
let mut sorted = idx.clone();
|
||
sorted.sort_unstable();
|
||
assert_eq!(sorted, (0..20).collect::<Vec<_>>());
|
||
}
|
||
|
||
#[test]
|
||
fn lcg_shuffle_different_seeds_differ() {
|
||
let mut a: Vec<usize> = (0..20).collect();
|
||
let mut b: Vec<usize> = (0..20).collect();
|
||
lcg_shuffle(&mut a, 1);
|
||
lcg_shuffle(&mut b, 2);
|
||
assert_ne!(a, b, "different seeds should produce different orders");
|
||
}
|
||
|
||
#[test]
|
||
fn heatmap_to_keypoints_shape() {
|
||
let hm = Tensor::zeros([2, 17, 8, 8], (Kind::Float, Device::Cpu));
|
||
let kp = heatmap_to_keypoints(&hm);
|
||
assert_eq!(kp.size(), [2, 17, 2]);
|
||
}
|
||
|
||
#[test]
|
||
fn heatmap_to_keypoints_center_peak() {
|
||
// Create a heatmap with a single peak at the center (4, 4) of an 8×8 map.
|
||
let mut hm = Tensor::zeros([1, 1, 8, 8], (Kind::Float, Device::Cpu));
|
||
let _ = hm.narrow(2, 4, 1).narrow(3, 4, 1).fill_(1.0);
|
||
let kp = heatmap_to_keypoints(&hm);
|
||
let x: f64 = kp.double_value(&[0, 0, 0]);
|
||
let y: f64 = kp.double_value(&[0, 0, 1]);
|
||
// Center pixel 4 → normalised 4/7 ≈ 0.571
|
||
assert!((x - 4.0 / 7.0).abs() < 1e-4, "x={x}");
|
||
assert!((y - 4.0 / 7.0).abs() < 1e-4, "y={y}");
|
||
}
|
||
|
||
#[test]
|
||
fn trainer_train_completes() {
|
||
let cfg = tiny_config();
|
||
let train_ds = tiny_synthetic_dataset(8);
|
||
let val_ds = tiny_synthetic_dataset(4);
|
||
|
||
let mut trainer = Trainer::new(cfg);
|
||
let tmpdir = tempfile::tempdir().unwrap();
|
||
trainer.config.checkpoint_dir = tmpdir.path().join("checkpoints");
|
||
trainer.config.log_dir = tmpdir.path().join("logs");
|
||
|
||
let result = trainer.train(&train_ds, &val_ds).unwrap();
|
||
assert!(result.final_train_loss.is_finite());
|
||
assert!(!result.training_history.is_empty());
|
||
}
|
||
}
|