Files
rustytorch/crates/models/rtx-csm/examples/lora_eval.rs
T
osobhandClaude Opus 4.7 a5cedfb46a rtx-csm: emotional_speech_guide — CREMA-D vs RAVDESS firdhokk verdict
8-gen bench (4 emotions × 2 corpora) at seed=42 against firdhokk
Whisper-LV3:

  target    RAVDESS              CREMA-D
  happy     happy (0.999) ✓      happy (0.999) ✓
  angry     neutral (0.92)       sad (0.99)
  fearful   happy (0.998)        fearful (0.984) ✓
  sad       angry (0.99)         fearful (0.99)

CREMA-D 2/4 vs RAVDESS 1/4. Larger / more naturalistic corpus
produces more class-pure fearful direction. Neither corpus solves
angry or sad — recipe shifts into 'vague expressivity' rather than
class-specific corners.

Practical: prefer CREMA-D when available; A/B both per emotion if
class precision matters.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-30 00:01:02 -07:00

149 lines
4.4 KiB
Rust

//! Held-out cross-entropy evaluator for LoRA adapters.
//!
//! Runs teacher-forced `forward_loss` over a held-out manifest (same JSONL
//! format as `lora_train_emotional`) and writes a JSON report. Run it twice
//! for an A/B:
//!
//! ```bash
//! # baseline (no adapter)
//! lora_eval --eval-manifest test.jsonl --report /tmp/base.json
//!
//! # with adapter
//! lora_eval --eval-manifest test.jsonl --lora /tmp/voice.safetensors \
//! --report /tmp/lora.json
//!
//! # compare summaries
//! diff <(jq .summary /tmp/base.json) <(jq .summary /tmp/lora.json)
//! ```
//!
//! Lower mean / median / p90 loss = adapter is fitting the held-out audio
//! better. If LoRA loss is higher than base, your adapter has overfit the
//! training set.
use anyhow::Result;
use clap::Parser;
use rtx_csm::Generator;
use rtx_csm::training::{EvalSummary, TrainingDataset, evaluate_held_out};
use std::path::PathBuf;
#[derive(Debug, Parser)]
struct Cli {
/// JSONL manifest of held-out (wav, transcript, [emotion_tag]) rows.
/// Same format as `lora_train_emotional`.
#[arg(long)]
eval_manifest: PathBuf,
/// Output JSON report path.
#[arg(long)]
report: PathBuf,
/// Optional LoRA adapter to apply before scoring. When omitted, the
/// base CSM-1B is scored — that's the report you compare against.
#[arg(long)]
lora: Option<PathBuf>,
/// Force LoRA hyperparameters (overrides metadata). See `apply_lora_adapter`.
#[arg(long)]
lora_rank: Option<usize>,
#[arg(long)]
lora_alpha: Option<f32>,
#[arg(long, default_value_t = false)]
extended_lora: bool,
/// Optional quantized GGUF (Q8/Q4_K_M). Combines with `--lora` per Phase 12.6.
#[arg(long)]
quantized_gguf: Option<PathBuf>,
/// Default speaker id when a manifest row doesn't specify one.
#[arg(long, default_value_t = 0)]
speaker: u32,
/// Frames sampled per example. 0 = score every frame (slow).
#[arg(long, default_value_t = 8)]
frames_per_example: usize,
/// RNG seed for frame sampling. Two runs with the same seed score the
/// same frames in the same order — required for a fair A/B.
#[arg(long, default_value_t = 42)]
seed: u64,
#[arg(long)]
cpu: bool,
}
#[derive(Debug, serde::Serialize)]
struct Report {
/// What was evaluated: base or path-to-adapter (or path with quantized base).
label: String,
n_examples: usize,
frames_per_example_cap: usize,
seed: u64,
summary: EvalSummary,
rows: Vec<rtx_csm::training::EvalRow>,
}
fn main() -> Result<()> {
tracing_subscriber::fmt().init();
let cli = Cli::parse();
let device = if cli.cpu {
candle_core::Device::Cpu
} else {
Generator::default_device()?
};
println!("device: {device:?}");
let mut generator = if let Some(gguf) = cli.quantized_gguf.as_ref() {
Generator::load_csm_1b_quantized(gguf, &device, /* enable_cfg */ false)?
} else {
Generator::load_csm_1b(&device)?
};
println!("model loaded");
let label = if let Some(lora_path) = cli.lora.as_ref() {
let extended_override = if cli.extended_lora { Some(true) } else { None };
rtx_csm::training::apply_lora_adapter(
&mut generator,
lora_path,
cli.lora_rank,
cli.lora_alpha,
extended_override,
&device,
)?;
format!("lora:{}", lora_path.display())
} else {
"base".to_string()
};
println!("loading eval manifest from {}", cli.eval_manifest.display());
let dataset =
TrainingDataset::load_from_manifest(&cli.eval_manifest, cli.speaker, &mut generator)?;
println!("dataset: {} examples", dataset.len());
let rows = evaluate_held_out(&mut generator, &dataset, cli.frames_per_example, cli.seed)?;
let summary = EvalSummary::from_rows(&rows);
println!(
"summary: n={} frames={} mean={:.4} median={:.4} p90={:.4} min={:.4} max={:.4}",
summary.n_examples,
summary.n_frames_total,
summary.mean_loss,
summary.median_loss,
summary.p90_loss,
summary.min_loss,
summary.max_loss,
);
let report = Report {
label,
n_examples: rows.len(),
frames_per_example_cap: cli.frames_per_example,
seed: cli.seed,
summary,
rows,
};
let json = serde_json::to_string_pretty(&report)?;
std::fs::write(&cli.report, json)?;
println!("\n✓ wrote report to {}", cli.report.display());
Ok(())
}