use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::interop::safetensors::SaveError;
use crate::optim::{LrScheduler, ReduceLrOnPlateau};
use crate::{AutodiffError, Tensor};
use super::logger_io;
use super::metrics::Metrics;
use super::sequential::Sequential;
use super::training::History;
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Monitor {
Loss,
ValLoss,
ValMetric(Metrics),
}
impl Monitor {
fn value_at(self, history: &History, epoch_local: usize) -> Option<f32> {
match self {
Monitor::Loss => history.loss.get(epoch_local).copied(),
Monitor::ValLoss => history.val_loss.get(epoch_local).copied(),
Monitor::ValMetric(m) => history.val_metrics.get(epoch_local).and_then(|r| match m {
Metrics::Accuracy => r.accuracy,
Metrics::Precision => r.precision,
Metrics::Recall => r.recall,
Metrics::F1 => r.f1,
Metrics::ConfusionMatrix => None,
}),
}
}
fn requires_validation(self) -> bool {
matches!(self, Monitor::ValLoss | Monitor::ValMetric(_))
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MonitorMode {
Min,
Max,
}
impl MonitorMode {
fn initial_best(self) -> f32 {
match self {
MonitorMode::Min => f32::INFINITY,
MonitorMode::Max => f32::NEG_INFINITY,
}
}
fn is_improvement(self, value: f32, best: f32, min_delta: f32) -> bool {
match self {
MonitorMode::Min => value < best - min_delta,
MonitorMode::Max => value > best + min_delta,
}
}
}
#[derive(Debug)]
pub struct EarlyStopping {
monitor: Monitor,
mode: MonitorMode,
patience: usize,
min_delta: f32,
restore_best_weights: bool,
best: Option<f32>,
best_epoch: Option<usize>,
wait: usize,
stopped_epoch: Option<usize>,
best_state: Option<HashMap<String, Tensor<f32>>>,
}
impl EarlyStopping {
pub fn new(patience: usize) -> Self {
EarlyStopping {
monitor: Monitor::ValLoss,
mode: MonitorMode::Min,
patience,
min_delta: 0.0,
restore_best_weights: false,
best: None,
best_epoch: None,
wait: 0,
stopped_epoch: None,
best_state: None,
}
}
pub fn monitor(mut self, m: Monitor) -> Self {
self.monitor = m;
self
}
pub fn mode(mut self, m: MonitorMode) -> Self {
self.mode = m;
self
}
pub fn min_delta(mut self, d: f32) -> Result<Self, AutodiffError> {
if !d.is_finite() || d < 0.0 {
return Err(AutodiffError::InvalidArgument(format!(
"EarlyStopping::min_delta: must be finite and >= 0.0, got {d}"
)));
}
self.min_delta = d;
Ok(self)
}
pub fn restore_best_weights(mut self, on: bool) -> Self {
self.restore_best_weights = on;
self
}
pub(super) fn restores_best_weights(&self) -> bool {
self.restore_best_weights
}
pub fn stopped_epoch(&self) -> Option<usize> {
self.stopped_epoch
}
pub fn best_value(&self) -> Option<f32> {
self.best
}
pub fn best_epoch(&self) -> Option<usize> {
self.best_epoch
}
fn requires_validation(&self) -> bool {
self.monitor.requires_validation()
}
pub(super) fn monitor_value_at(&self, history: &History, epoch_local: usize) -> Option<f32> {
self.monitor.value_at(history, epoch_local)
}
pub(super) fn reset_for_fit(&mut self) {
self.best = None;
self.best_epoch = None;
self.wait = 0;
self.stopped_epoch = None;
self.best_state = None;
}
pub(super) fn observe(
&mut self,
value: f32,
epoch_local: usize,
snapshot: impl FnOnce() -> HashMap<String, Tensor<f32>>,
) -> bool {
let best_so_far = self.best.unwrap_or(self.mode.initial_best());
if self.mode.is_improvement(value, best_so_far, self.min_delta) {
self.best = Some(value);
self.best_epoch = Some(epoch_local);
self.wait = 0;
if self.restore_best_weights {
self.best_state = Some(snapshot());
}
false
} else {
self.wait += 1;
if self.wait >= self.patience {
self.stopped_epoch = Some(epoch_local);
true
} else {
false
}
}
}
pub(super) fn take_restore_state(&mut self) -> Option<HashMap<String, Tensor<f32>>> {
if self.restore_best_weights {
self.best_state.take()
} else {
None
}
}
}
#[derive(Debug)]
pub struct ModelCheckpoint {
monitor: Monitor,
mode: MonitorMode,
save_best_only: bool,
best: Option<f32>,
best_epoch: Option<usize>,
epoch_count: usize,
state: Option<HashMap<String, Tensor<f32>>>,
file_path: Option<PathBuf>,
}
impl ModelCheckpoint {
pub fn new() -> Self {
ModelCheckpoint {
monitor: Monitor::ValLoss,
mode: MonitorMode::Min,
save_best_only: true,
best: None,
best_epoch: None,
epoch_count: 0,
state: None,
file_path: None,
}
}
pub fn monitor(mut self, m: Monitor) -> Self {
self.monitor = m;
self
}
pub fn mode(mut self, m: MonitorMode) -> Self {
self.mode = m;
self
}
pub fn save_best_only(mut self, on: bool) -> Self {
self.save_best_only = on;
self
}
pub fn to_file(mut self, path: impl AsRef<Path>) -> Self {
self.file_path = Some(path.as_ref().to_path_buf());
self
}
pub fn best_state_dict(&self) -> Option<&HashMap<String, Tensor<f32>>> {
self.state.as_ref()
}
pub fn take_best_state_dict(&mut self) -> Option<HashMap<String, Tensor<f32>>> {
self.state.take()
}
pub fn best_value(&self) -> Option<f32> {
self.best
}
pub fn best_epoch(&self) -> Option<usize> {
self.best_epoch
}
fn requires_validation(&self) -> bool {
self.monitor.requires_validation()
}
pub(super) fn monitor_value_at(&self, history: &History, epoch_local: usize) -> Option<f32> {
self.monitor.value_at(history, epoch_local)
}
pub(super) fn observe(&mut self, value: f32, model: &Sequential) -> Result<(), SaveError> {
let best_so_far = self.best.unwrap_or(self.mode.initial_best());
let improved = self.mode.is_improvement(value, best_so_far, 0.0);
let mut persist_result = Ok(());
if improved || !self.save_best_only {
let state = model.state_dict();
if let Some(path) = &self.file_path {
persist_result = persist(path, &state);
}
if persist_result.is_ok() {
if improved {
self.best = Some(value);
self.best_epoch = Some(self.epoch_count);
}
self.state = Some(state);
}
}
self.epoch_count += 1;
persist_result
}
}
fn persist(path: &Path, state: &HashMap<String, Tensor<f32>>) -> Result<(), SaveError> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent).map_err(SaveError::Io)?;
}
crate::interop::safetensors::save_safetensors_f32(path, state)
}
impl Default for ModelCheckpoint {
fn default() -> Self {
Self::new()
}
}
pub struct LrSchedule {
kind: LrScheduleKind,
next_epoch: usize,
}
enum LrScheduleKind {
PerEpoch(Box<dyn LrScheduler>),
Plateau {
sched: ReduceLrOnPlateau,
monitor: Monitor,
},
}
impl LrSchedule {
pub fn per_epoch(s: impl LrScheduler + 'static) -> Self {
LrSchedule {
kind: LrScheduleKind::PerEpoch(Box::new(s)),
next_epoch: 0,
}
}
pub fn plateau(s: ReduceLrOnPlateau) -> Self {
Self::plateau_with_monitor(s, Monitor::ValLoss)
}
pub fn plateau_with_monitor(s: ReduceLrOnPlateau, monitor: Monitor) -> Self {
LrSchedule {
kind: LrScheduleKind::Plateau { sched: s, monitor },
next_epoch: 0,
}
}
pub fn epoch(&self) -> usize {
self.next_epoch
}
fn monitor(&self) -> Option<Monitor> {
match &self.kind {
LrScheduleKind::PerEpoch(_) => None,
LrScheduleKind::Plateau { monitor, .. } => Some(*monitor),
}
}
fn requires_validation(&self) -> bool {
matches!(self.monitor(), Some(m) if m.requires_validation())
}
pub(super) fn monitor_value_at(&self, history: &History, epoch_local: usize) -> Option<f32> {
self.monitor()
.and_then(|m| m.value_at(history, epoch_local))
}
pub(super) fn lr_for_epoch_begin(&self) -> f32 {
match &self.kind {
LrScheduleKind::PerEpoch(s) => s.lr_at(self.next_epoch),
LrScheduleKind::Plateau { sched, .. } => sched.current_lr(),
}
}
pub(super) fn advance(&mut self, monitor_value: Option<f32>) -> Result<(), AutodiffError> {
match &mut self.kind {
LrScheduleKind::PerEpoch(_) => {}
LrScheduleKind::Plateau { sched, .. } => {
let value = monitor_value.ok_or_else(|| {
AutodiffError::InvalidArgument(
"LrSchedule::advance: Plateau には monitor_value が必要\
(呼び出し元の契約違反)"
.to_string(),
)
})?;
sched.step(value)?;
}
}
self.next_epoch += 1;
Ok(())
}
}
impl std::fmt::Debug for LrSchedule {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.kind {
LrScheduleKind::PerEpoch(_) => f
.debug_struct("LrSchedule::PerEpoch")
.field("next_epoch", &self.next_epoch)
.finish(),
LrScheduleKind::Plateau { sched, monitor } => f
.debug_struct("LrSchedule::Plateau")
.field("current_lr", &sched.current_lr())
.field("monitor", monitor)
.field("next_epoch", &self.next_epoch)
.finish(),
}
}
}
pub struct CsvLogger {
path: PathBuf,
append: bool,
state: Option<CsvFitState>,
}
type EpochEndFn = Box<dyn FnMut(usize, &History) -> Result<(), AutodiffError>>;
struct CsvFitState {
cols: Vec<&'static str>,
file: std::fs::File,
}
impl CsvLogger {
pub fn new(path: impl AsRef<Path>) -> Self {
Self {
path: path.as_ref().to_path_buf(),
append: false,
state: None,
}
}
pub fn append(mut self, on: bool) -> Self {
self.append = on;
self
}
fn err(&self, method: &str, detail: impl std::fmt::Display) -> AutodiffError {
AutodiffError::InvalidArgument(format!(
"Sequential::{method}: CsvLogger({}): {detail}",
self.path.display()
))
}
pub(super) fn begin_fit(
&mut self,
method: &str,
has_validation: bool,
metrics: &[Metrics],
) -> Result<(), AutodiffError> {
self.state = None;
let cols = logger_io::columns(has_validation, metrics);
let header = logger_io::csv_header(&cols);
let file = self
.open_for_fit(&header)
.map_err(|d| self.err(method, d))?;
self.state = Some(CsvFitState { cols, file });
Ok(())
}
fn open_for_fit(&self, header: &str) -> Result<std::fs::File, String> {
use std::io::{Read, Seek, SeekFrom, Write};
logger_io::ensure_parent_dir(&self.path)?;
let existing_nonempty = self.append && logger_io::existing_nonempty(&self.path)?;
if existing_nonempty {
let first = logger_io::read_first_line_limited(&self.path)?.unwrap_or_default();
if first != header {
return Err(format!(
"既存ファイルのヘッダ({first:?})が今回の列集合({header:?})と一致しない"
));
}
let mut f = std::fs::OpenOptions::new()
.read(true)
.append(true)
.open(&self.path)
.map_err(|e| e.to_string())?;
f.seek(SeekFrom::End(-1)).map_err(|e| e.to_string())?;
let mut last = [0u8; 1];
f.read_exact(&mut last).map_err(|e| e.to_string())?;
if last[0] != b'\n' {
f.write_all(b"\n")
.and_then(|_| f.flush())
.map_err(|e| e.to_string())?;
}
return Ok(f);
}
let mut f = if self.append {
std::fs::OpenOptions::new()
.append(true)
.create(true)
.open(&self.path)
} else {
std::fs::File::create(&self.path)
}
.map_err(|e| e.to_string())?;
f.write_all(format!("{header}\n").as_bytes())
.and_then(|_| f.flush())
.map_err(|e| e.to_string())?;
Ok(f)
}
pub(super) fn write_epoch(
&mut self,
method: &str,
history: &History,
epoch_local: usize,
) -> Result<(), AutodiffError> {
use std::io::Write;
let Some(st) = self.state.as_mut() else {
return Err(self.err(method, "fit 開始前に epoch 末が呼ばれた(内部契約違反)"));
};
let line = logger_io::collect_values(&st.cols, history, epoch_local)
.map(|v| format!("{}\n", logger_io::csv_row(epoch_local, &v)));
let res = line.and_then(|l| {
st.file
.write_all(l.as_bytes())
.and_then(|_| st.file.flush())
.map_err(|e| e.to_string())
});
res.map_err(|d| self.err(method, d))
}
pub(super) fn end_fit(&mut self) {
self.state = None;
}
}
impl std::fmt::Debug for CsvLogger {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CsvLogger")
.field("path", &self.path)
.field("append", &self.append)
.finish()
}
}
pub struct JsonLogger {
path: PathBuf,
append: bool,
state: Option<JsonFitState>,
}
struct JsonFitState {
cols: Vec<&'static str>,
elems: Vec<String>,
}
impl JsonLogger {
pub fn new(path: impl AsRef<Path>) -> Self {
Self {
path: path.as_ref().to_path_buf(),
append: false,
state: None,
}
}
pub fn append(mut self, on: bool) -> Self {
self.append = on;
self
}
fn err(&self, method: &str, detail: impl std::fmt::Display) -> AutodiffError {
AutodiffError::InvalidArgument(format!(
"Sequential::{method}: JsonLogger({}): {detail}",
self.path.display()
))
}
pub(super) fn begin_fit(
&mut self,
method: &str,
has_validation: bool,
metrics: &[Metrics],
) -> Result<(), AutodiffError> {
self.state = None;
let cols = logger_io::columns(has_validation, metrics);
let elems = self
.load_initial(&cols)
.and_then(|elems| {
logger_io::write_atomic(&self.path, logger_io::json_array(&elems).as_bytes())
.map(|_| elems)
})
.map_err(|d| self.err(method, d))?;
self.state = Some(JsonFitState { cols, elems });
Ok(())
}
fn load_initial(&self, cols: &[&'static str]) -> Result<Vec<String>, String> {
logger_io::ensure_parent_dir(&self.path)?;
let nonempty = self.append && logger_io::existing_nonempty(&self.path)?;
if !nonempty {
return Ok(Vec::new());
}
let text = logger_io::read_limited(&self.path)?;
let parsed = logger_io::parse_log_array(&text)
.map_err(|e| format!("既存ファイルが JSON ログとして不正: {e}"))?;
for (i, el) in parsed.iter().enumerate() {
for key in std::iter::once("epoch").chain(cols.iter().copied()) {
if !el.keys.iter().any(|k| k == key) {
return Err(format!("既存の要素 {i} にキー {key:?} が無い"));
}
}
}
Ok(parsed.into_iter().map(|e| e.raw).collect())
}
pub(super) fn write_epoch(
&mut self,
method: &str,
history: &History,
epoch_local: usize,
) -> Result<(), AutodiffError> {
let Some(st) = self.state.as_mut() else {
return Err(self.err(method, "fit 開始前に epoch 末が呼ばれた(内部契約違反)"));
};
let res = logger_io::collect_values(&st.cols, history, epoch_local).and_then(|v| {
st.elems
.push(logger_io::json_row(&st.cols, epoch_local, &v));
logger_io::write_atomic(&self.path, logger_io::json_array(&st.elems).as_bytes())
});
res.map_err(|d| self.err(method, d))
}
pub(super) fn end_fit(&mut self) {
self.state = None;
}
}
impl std::fmt::Debug for JsonLogger {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("JsonLogger")
.field("path", &self.path)
.field("append", &self.append)
.field("rows", &self.state.as_ref().map(|s| s.elems.len()))
.finish()
}
}
pub struct LambdaCallback {
f: EpochEndFn,
calls: usize,
}
impl LambdaCallback {
pub fn on_epoch_end(
f: impl FnMut(usize, &History) -> Result<(), AutodiffError> + 'static,
) -> Self {
Self {
f: Box::new(f),
calls: 0,
}
}
pub(super) fn invoke(
&mut self,
epoch_local: usize,
history: &History,
) -> Result<(), AutodiffError> {
self.calls += 1;
(self.f)(epoch_local, history)
}
}
impl std::fmt::Debug for LambdaCallback {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LambdaCallback")
.field("calls", &self.calls)
.finish()
}
}
#[derive(Debug)]
pub struct EmaCallback {
decay: f32,
ema: Option<crate::optim::ExponentialMovingAverage>,
}
impl EmaCallback {
pub fn new(decay: f32) -> Result<Self, AutodiffError> {
crate::optim::ExponentialMovingAverage::new(decay, &[])?;
Ok(Self { decay, ema: None })
}
pub fn decay(&self) -> f32 {
self.decay
}
pub fn num_updates(&self) -> u64 {
self.ema.as_ref().map_or(0, |e| e.num_updates())
}
pub fn shadow_state_dict(&self) -> Option<HashMap<String, Tensor<f32>>> {
self.ema.as_ref().map(|e| e.shadow_state_dict())
}
pub(super) fn begin_fit(&mut self, model: &Sequential) -> Result<(), AutodiffError> {
if self.ema.is_none() {
self.ema = Some(crate::optim::ExponentialMovingAverage::from_named(
self.decay,
model.named_parameters(),
)?);
return Ok(());
}
if let Some(ema) = self.ema.as_ref() {
let named = model.named_parameters();
let expected = ema.shadow_parameters().len();
if named.len() != expected {
return Err(AutodiffError::InvalidArgument(format!(
"Callback::Ema: 継続中の shadow のパラメータ数({expected})が \
モデルのパラメータ数({})と一致しない",
named.len()
)));
}
for (name, param) in &named {
let Some(shadow) = ema.shadow(name) else {
return Err(AutodiffError::InvalidArgument(format!(
"Callback::Ema: モデルのパラメータ `{name}` が継続中の shadow に無い"
)));
};
if shadow.shape() != param.shape() {
return Err(AutodiffError::Shape(crate::ShapeError::ShapeMismatch {
lhs: shadow.shape().to_vec(),
rhs: param.shape().to_vec(),
}));
}
}
}
Ok(())
}
pub(super) fn update(&mut self, model: &Sequential) -> Result<(), AutodiffError> {
match self.ema.as_mut() {
Some(e) => e.update_named(model.named_parameters()),
None => Err(AutodiffError::InvalidArgument(
"EmaCallback: begin_fit 前に update が呼ばれた(内部不変条件違反)".into(),
)),
}
}
pub(super) fn shadow_for_eval(&self) -> Result<HashMap<String, Tensor<f32>>, AutodiffError> {
self.shadow_state_dict().ok_or_else(|| {
AutodiffError::InvalidArgument(
"EmaCallback: begin_fit 前に shadow が要求された(内部不変条件違反)".into(),
)
})
}
}
type FitSnapshot = (
crate::optim::AveragedModel,
usize,
HashMap<String, Tensor<f32>>,
Option<f32>,
);
#[derive(Debug)]
pub struct SwaCallback {
swa_start: usize,
swa_lr: f32,
anneal_epochs: usize,
anneal: crate::optim::SwaAnneal,
epochs_seen: usize,
avg: Option<crate::optim::AveragedModel>,
raw: Option<HashMap<String, Tensor<f32>>>,
restored_for_fit: bool,
fit_snapshot: Option<FitSnapshot>,
anneal_base_lr: Option<f32>,
}
fn state_dicts_bit_equal(
a: &HashMap<String, Tensor<f32>>,
b: &HashMap<String, Tensor<f32>>,
) -> bool {
if a.len() != b.len() {
return false;
}
a.iter().all(|(k, ta)| {
let Some(tb) = b.get(k) else { return false };
if ta.shape() != tb.shape() {
return false;
}
let (ca, cb) = (ta.contiguous(), tb.contiguous());
match (ca.as_slice(), cb.as_slice()) {
(Some(xa), Some(xb)) => xa.iter().zip(xb).all(|(p, q)| p.to_bits() == q.to_bits()),
_ => false,
}
})
}
impl SwaCallback {
pub fn new(
swa_start: usize,
swa_lr: f32,
anneal_epochs: usize,
anneal: crate::optim::SwaAnneal,
) -> Result<Self, AutodiffError> {
crate::optim::SwaLr::new(swa_lr, swa_lr, anneal_epochs, anneal)?;
Ok(Self {
swa_start,
swa_lr,
anneal_epochs,
anneal,
epochs_seen: 0,
avg: None,
raw: None,
restored_for_fit: false,
fit_snapshot: None,
anneal_base_lr: None,
})
}
pub fn swa_start(&self) -> usize {
self.swa_start
}
pub fn swa_lr(&self) -> f32 {
self.swa_lr
}
pub fn anneal_epochs(&self) -> usize {
self.anneal_epochs
}
pub fn anneal(&self) -> crate::optim::SwaAnneal {
self.anneal
}
pub fn n_averaged(&self) -> u64 {
self.avg.as_ref().map_or(0, |a| a.n_averaged())
}
pub fn averaged_state_dict(&self) -> Option<HashMap<String, Tensor<f32>>> {
self.avg.as_ref().map(|a| a.averaged_state_dict())
}
pub fn raw_state_dict(&self) -> Option<HashMap<String, Tensor<f32>>> {
self.raw.clone()
}
fn invariant(what: &str) -> AutodiffError {
AutodiffError::InvalidArgument(format!(
"SwaCallback: begin_fit 前に {what} が呼ばれた(内部不変条件違反)"
))
}
pub(super) fn begin_fit(&mut self, model: &mut Sequential) -> Result<(), AutodiffError> {
let Some(avg) = self.avg.as_ref() else {
self.avg = Some(crate::optim::AveragedModel::from_named(
model.named_parameters(),
)?);
return Ok(());
};
{
let named = model.named_parameters();
let expected = avg.averaged_parameters().len();
if named.len() != expected {
return Err(AutodiffError::InvalidArgument(format!(
"Callback::Swa: 継続中の平均のパラメータ数({expected})が \
モデルのパラメータ数({})と一致しない",
named.len()
)));
}
for (name, param) in &named {
let Some(a) = avg.averaged(name) else {
return Err(AutodiffError::InvalidArgument(format!(
"Callback::Swa: モデルのパラメータ `{name}` が継続中の平均に無い"
)));
};
if a.shape() != param.shape() {
return Err(AutodiffError::Shape(crate::ShapeError::ShapeMismatch {
lhs: a.shape().to_vec(),
rhs: param.shape().to_vec(),
}));
}
}
}
if self.raw.is_some() && avg.n_averaged() > 0 {
if state_dicts_bit_equal(&model.state_dict(), &avg.averaged_state_dict()) {
if let Some(raw) = self.raw.take() {
let raw_snapshot = raw.clone();
if let Err(e) = model.load_state_dict(raw.clone()) {
self.raw = Some(raw);
return Err(e);
}
self.restored_for_fit = true;
self.fit_snapshot = Some((
avg.clone(),
self.epochs_seen,
raw_snapshot,
self.anneal_base_lr,
));
}
} else {
self.raw = None;
}
}
Ok(())
}
pub(super) fn lr_for_epoch(&mut self, current_lr: f32) -> Result<Option<f32>, AutodiffError> {
use crate::optim::LrScheduler;
if self.avg.is_none() {
return Err(Self::invariant("lr_for_epoch"));
}
if self.epochs_seen < self.swa_start {
return Ok(None);
}
let base = self.anneal_base_lr.unwrap_or(current_lr);
let sched = crate::optim::SwaLr::new(base, self.swa_lr, self.anneal_epochs, self.anneal)?;
self.anneal_base_lr = Some(base);
Ok(Some(sched.lr_at(self.epochs_seen - self.swa_start)))
}
pub(super) fn update_after_epoch(&mut self, model: &Sequential) -> Result<(), AutodiffError> {
let Some(avg) = self.avg.as_mut() else {
return Err(Self::invariant("update"));
};
let next = self.epochs_seen.checked_add(1).ok_or_else(|| {
AutodiffError::InvalidArgument("SwaCallback: epoch 数が usize を超えた".into())
})?;
if self.epochs_seen >= self.swa_start {
avg.update_named(model.named_parameters())?;
}
self.epochs_seen = next;
Ok(())
}
pub(super) fn abort_fit(&mut self, model: &mut Sequential) {
if !std::mem::take(&mut self.restored_for_fit) {
self.fit_snapshot = None;
return;
}
let mut snap_raw = None;
if let Some((snap_avg, snap_epochs, raw, snap_base)) = self.fit_snapshot.take() {
self.avg = Some(snap_avg);
self.epochs_seen = snap_epochs;
self.anneal_base_lr = snap_base;
snap_raw = Some(raw);
}
let Some(avg) = self.avg.as_ref() else { return };
let backup = model.state_dict();
if model.load_state_dict(avg.averaged_state_dict()).is_ok() {
self.raw = Some(snap_raw.unwrap_or(backup));
}
}
pub(super) fn finish_fit(&mut self, model: &mut Sequential) -> Result<(), AutodiffError> {
self.restored_for_fit = false;
self.fit_snapshot = None;
let Some(avg) = self.avg.as_ref() else {
return Err(Self::invariant("finish"));
};
if avg.n_averaged() == 0 {
return Ok(());
}
let backup = model.state_dict();
model.load_state_dict(avg.averaged_state_dict())?;
self.raw = Some(backup);
Ok(())
}
}
#[non_exhaustive]
#[derive(Debug)]
pub enum Callback {
EarlyStopping(EarlyStopping),
ModelCheckpoint(ModelCheckpoint),
LrSchedule(LrSchedule),
CsvLogger(CsvLogger),
JsonLogger(JsonLogger),
Lambda(LambdaCallback),
Ema(EmaCallback),
Swa(SwaCallback),
}
impl Callback {
pub(super) fn requires_validation(&self) -> bool {
match self {
Callback::EarlyStopping(es) => es.requires_validation(),
Callback::ModelCheckpoint(mc) => mc.requires_validation(),
Callback::LrSchedule(ls) => ls.requires_validation(),
Callback::CsvLogger(_)
| Callback::JsonLogger(_)
| Callback::Lambda(_)
| Callback::Ema(_)
| Callback::Swa(_) => false,
}
}
pub(super) fn end_fit(&mut self) {
match self {
Callback::CsvLogger(l) => l.end_fit(),
Callback::JsonLogger(l) => l.end_fit(),
_ => {}
}
}
pub(super) fn monitor(&self) -> Option<Monitor> {
match self {
Callback::EarlyStopping(es) => Some(es.monitor),
Callback::ModelCheckpoint(mc) => Some(mc.monitor),
Callback::LrSchedule(ls) => ls.monitor(),
Callback::CsvLogger(_)
| Callback::JsonLogger(_)
| Callback::Lambda(_)
| Callback::Ema(_)
| Callback::Swa(_) => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn swa_internal_invariant_violations_are_typed_errors() {
let mut model = Sequential::new().add_linear(2, 1, 1).expect("test fixture");
let mut cb =
SwaCallback::new(0, 0.1, 0, crate::optim::SwaAnneal::Cos).expect("test fixture");
assert!(matches!(
cb.update_after_epoch(&model),
Err(AutodiffError::InvalidArgument(m)) if m.contains("内部不変条件違反")
));
assert!(matches!(
cb.finish_fit(&mut model),
Err(AutodiffError::InvalidArgument(m)) if m.contains("内部不変条件違反")
));
assert!(matches!(
cb.lr_for_epoch(0.1),
Err(AutodiffError::InvalidArgument(m)) if m.contains("内部不変条件違反")
));
}
#[test]
fn swa_invalid_base_lr_is_not_stored() {
let mut model = Sequential::new().add_linear(2, 1, 1).expect("test fixture");
let mut cb =
SwaCallback::new(0, 0.05, 2, crate::optim::SwaAnneal::Cos).expect("test fixture");
cb.begin_fit(&mut model).expect("test fixture");
for bad in [f32::NAN, 0.0, -0.1] {
assert!(matches!(
cb.lr_for_epoch(bad),
Err(AutodiffError::InvalidArgument(_))
));
assert_eq!(cb.anneal_base_lr, None);
}
let lr = cb.lr_for_epoch(0.2).expect("valid base").expect("in swa");
assert!((lr - 0.2).abs() < 1e-6);
assert_eq!(cb.anneal_base_lr, Some(0.2));
}
fn hist(loss: Vec<f32>, val_loss: Vec<f32>) -> History {
History {
loss,
val_loss,
lr: Vec::new(),
val_metrics: Vec::new(),
}
}
#[test]
fn monitor_value_at_reads_correct_column() {
let h = hist(vec![1.0, 2.0], vec![10.0, 20.0]);
assert_eq!(Monitor::Loss.value_at(&h, 1), Some(2.0));
assert_eq!(Monitor::ValLoss.value_at(&h, 1), Some(20.0));
assert_eq!(Monitor::Loss.value_at(&h, 5), None);
}
#[test]
fn monitor_requires_validation_only_for_val_loss() {
assert!(!Monitor::Loss.requires_validation());
assert!(Monitor::ValLoss.requires_validation());
}
#[test]
fn min_mode_improvement_respects_min_delta() {
let mode = MonitorMode::Min;
assert!(mode.is_improvement(0.89, 1.0, 0.1));
assert!(!mode.is_improvement(0.91, 1.0, 0.1));
assert!(!mode.is_improvement(1.0, 1.0, 0.0));
}
#[test]
fn max_mode_improvement_respects_min_delta() {
let mode = MonitorMode::Max;
assert!(mode.is_improvement(1.11, 1.0, 0.1));
assert!(!mode.is_improvement(1.09, 1.0, 0.1));
}
#[test]
fn nan_value_is_never_an_improvement() {
for mode in [MonitorMode::Min, MonitorMode::Max] {
assert!(!mode.is_improvement(f32::NAN, mode.initial_best(), 0.0));
assert!(!mode.is_improvement(f32::NAN, 1.0, 0.0));
}
}
#[test]
fn nan_best_never_blocks_a_finite_improvement() {
assert!(!MonitorMode::Min.is_improvement(1.0, f32::NAN, 0.0));
}
#[test]
fn early_stopping_first_observation_always_improves() {
let mut es = EarlyStopping::new(2).monitor(Monitor::Loss);
let stop = es.observe(5.0, 0, HashMap::new);
assert!(!stop);
assert_eq!(es.best_value(), Some(5.0));
assert_eq!(es.best_epoch(), Some(0));
}
#[test]
fn early_stopping_nan_first_observation_never_becomes_best() {
let mut es = EarlyStopping::new(2).monitor(Monitor::Loss);
let stop = es.observe(f32::NAN, 0, HashMap::new);
assert!(
!stop,
"NaN 初回観測は改善扱いされないが、patience=2 の 1 回目では停止しない"
);
assert_eq!(es.best_value(), None);
assert_eq!(es.best_epoch(), None);
let stop2 = es.observe(f32::NAN, 1, HashMap::new);
assert!(stop2);
assert_eq!(es.stopped_epoch(), Some(1));
}
#[test]
fn early_stopping_patience_zero_does_not_stop_on_improving_epoch() {
let mut es = EarlyStopping::new(0).monitor(Monitor::Loss);
assert!(!es.observe(1.0, 0, HashMap::new));
assert!(es.observe(1.0, 1, HashMap::new));
assert_eq!(es.stopped_epoch(), Some(1));
}
#[test]
fn early_stopping_reset_for_fit_clears_state() {
let mut es = EarlyStopping::new(0).monitor(Monitor::Loss);
assert!(!es.observe(1.0, 0, HashMap::new));
assert!(es.observe(2.0, 1, HashMap::new));
assert_eq!(es.stopped_epoch(), Some(1));
es.reset_for_fit();
assert_eq!(es.stopped_epoch(), None);
assert_eq!(es.best_value(), None);
assert_eq!(es.best_epoch(), None);
}
#[test]
fn early_stopping_restore_best_weights_off_never_snapshots() {
let mut es = EarlyStopping::new(1).monitor(Monitor::Loss);
let mut calls = 0;
es.observe(1.0, 0, || {
calls += 1;
HashMap::new()
});
assert_eq!(
calls, 0,
"restore_best_weights=false ではスナップショットしない"
);
assert!(es.take_restore_state().is_none());
}
#[test]
fn early_stopping_restore_best_weights_on_snapshots_only_on_improvement() {
let mut es = EarlyStopping::new(2)
.monitor(Monitor::Loss)
.restore_best_weights(true);
let mut calls = 0;
{
let mut snap = || {
calls += 1;
let mut m = HashMap::new();
m.insert(
"k".to_string(),
Tensor::<f32>::new(vec![1.0], &[1]).unwrap(),
);
m
};
es.observe(2.0, 0, &mut snap); es.observe(3.0, 1, &mut snap); }
assert_eq!(calls, 1);
let restored = es.take_restore_state();
assert!(restored.is_some());
assert!(es.take_restore_state().is_none());
}
#[test]
fn model_checkpoint_epoch_counter_persists_across_observe_calls() {
let mut mc = ModelCheckpoint::new().monitor(Monitor::Loss);
let model = Sequential::new();
mc.observe(2.0, &model)
.expect("in-memory のみでは失敗しない");
mc.observe(1.0, &model)
.expect("in-memory のみでは失敗しない"); mc.observe(1.5, &model)
.expect("in-memory のみでは失敗しない"); assert_eq!(mc.best_value(), Some(1.0));
assert_eq!(mc.best_epoch(), Some(1));
}
#[test]
fn model_checkpoint_save_best_only_false_keeps_last_epoch_snapshot() {
let mut mc = ModelCheckpoint::new()
.monitor(Monitor::Loss)
.save_best_only(false);
let model = Sequential::new();
mc.observe(1.0, &model)
.expect("in-memory のみでは失敗しない");
mc.observe(2.0, &model)
.expect("in-memory のみでは失敗しない"); assert!(mc.best_state_dict().is_some());
assert_eq!(mc.best_value(), Some(1.0), "best 値自体は改善時のみ更新");
}
#[test]
fn model_checkpoint_mode_max_after_new_uses_correct_initial_best() {
let mut mc = ModelCheckpoint::new().mode(MonitorMode::Max);
let model = Sequential::new();
mc.observe(-1.0, &model)
.expect("in-memory のみでは失敗しない");
assert_eq!(mc.best_value(), Some(-1.0));
}
struct ConstAt {
value: f32,
}
impl LrScheduler for ConstAt {
fn lr_at(&self, _step: usize) -> f32 {
self.value
}
}
#[test]
fn lr_schedule_per_epoch_advance_increments_epoch_without_monitor() {
let mut ls = LrSchedule::per_epoch(ConstAt { value: 0.1 });
assert_eq!(ls.epoch(), 0);
assert_eq!(ls.lr_for_epoch_begin(), 0.1);
ls.advance(None).unwrap();
assert_eq!(ls.epoch(), 1);
assert_eq!(ls.monitor(), None);
assert!(!ls.requires_validation());
}
#[test]
fn lr_schedule_plateau_requires_validation_by_default() {
let sched = ReduceLrOnPlateau::new(
0.1,
fandhe_ai_autodiff::nn::optim::ReduceLrOnPlateauConfig::default(),
)
.unwrap();
let ls = LrSchedule::plateau(sched);
assert_eq!(ls.monitor(), Some(Monitor::ValLoss));
assert!(ls.requires_validation());
}
#[test]
fn lr_schedule_plateau_advance_without_monitor_value_is_rejected() {
let sched = ReduceLrOnPlateau::new(
0.1,
fandhe_ai_autodiff::nn::optim::ReduceLrOnPlateauConfig::default(),
)
.unwrap();
let mut ls = LrSchedule::plateau_with_monitor(sched, Monitor::Loss);
let err = ls.advance(None).unwrap_err();
assert!(matches!(err, AutodiffError::InvalidArgument(_)));
}
}