Training alerts

Get notified when model training finishes, crashes or hangs

Keras and Hugging Face call on_train_end when training completes, and a PyTorch loop needs only try/except. Both callbacks skip exceptions, and nothing inside the process fires when the out-of-memory killer ends it or a data loader hangs. Here are the hooks for all 3 frameworks, one webhook sender and an outside stall check for the 3 failures hooks miss.

4 min readNotifications and alerts

At a glance

  • Keras: Callback.on_train_end(logs) receives the last epoch's metrics.
  • Hugging Face: TrainerCallback.on_train_end; send only when state.is_world_process_zero.
  • An exception skips both callbacks, so wrap fit() or train() in try/except.
  • An OOM kill or a hang sends nothing; only an outside check catches them.

A send function you can reuse

Start with one small function that posts to a Discord or Slack webhook stored in an environment variable. Give the request a timeout and swallow its errors: a network blip should never crash a 6-hour run at the finish line.

Two limits to keep in mind: Discord content holds up to 2,000 characters, and Slack incoming webhooks take about 1 message per second with the key text instead of content.

Python
import os

import requests


def send(text):
    try:
        requests.post(
            os.environ["DISCORD_WEBHOOK_URL"],
            json={"content": text[:2000]},
            timeout=10,
        )
    except Exception as exc:
        print(f"notification failed: {exc!r}")

Keras: a callback with on_train_end

In Keras (latest 3.15.1), subclass keras.callbacks.Callback and override on_train_end(self, logs=None). logs holds the last epoch's metrics, so the final numbers go straight into the message.

Keras 3's fit() only reaches on_train_end when the epoch loop completes, so an exception during training skips it. Wrap model.fit() in try/except to report crashes too.

Python
import keras


class NotifyCallback(keras.callbacks.Callback):
    def on_train_end(self, logs=None):
        logs = logs or {}
        send(f"Training finished: loss {logs.get('loss', float('nan')):.4f}, "
             f"val_loss {logs.get('val_loss', float('nan')):.4f}")


try:
    model.fit(x_train, y_train, validation_split=0.1, epochs=50, callbacks=[NotifyCallback()])
except Exception as exc:
    send(f"Training crashed: {exc!r}")
    raise

PyTorch: try, except and finally around the loop

A plain PyTorch loop needs no callback class. Wrap the epochs in try/except, send a message when the loop completes and send the exception when it fails. Catching BaseException also reports Ctrl-C.

Track the start time and the best validation loss as you go, so the message says how long it took and how good it got.

Python
import time

started = time.monotonic()
best_val_loss = float("inf")
epoch = 0
try:
    for epoch in range(epochs):
        train_one_epoch(model, train_loader, optimizer)
        best_val_loss = min(best_val_loss, evaluate(model, val_loader))
except BaseException as exc:
    send(f"Training crashed in epoch {epoch} after {(time.monotonic() - started) / 60:.0f}m: {exc!r}")
    raise
send(f"Training finished in {(time.monotonic() - started) / 60:.0f}m, best val loss {best_val_loss:.4f}")

In a distributed script, send only from rank 0 so the other processes stay quiet.

Hugging Face Trainer: TrainerCallback.on_train_end

In Hugging Face transformers (latest 5.22.0, released 2026-10-29), subclass TrainerCallback and implement on_train_end(self, args, state, control, **kwargs). Pass it with callbacks=[...] when you create the Trainer, or add it later with trainer.add_callback().

state is a TrainerState with the numbers you want in the message:

  • state.global_step
  • state.best_metric and state.best_model_checkpoint, filled in only when you track the best model (metric_for_best_model or load_best_model_at_end=True) and evaluation runs
  • state.is_world_process_zero, which is true only on the main process

Check state.is_world_process_zero before sending, so a multi-GPU run sends one message instead of one per GPU. As with Keras, an exception skips the callback, so wrap trainer.train().

Python
from transformers import Trainer, TrainerCallback


class NotifyCallback(TrainerCallback):
    def on_train_end(self, args, state, control, **kwargs):
        if state.is_world_process_zero:
            send(f"Training finished at step {state.global_step}, epoch {state.epoch:.1f}")


trainer = Trainer(model=model, args=training_args, train_dataset=train_ds, callbacks=[NotifyCallback()])
try:
    trainer.train()
except Exception as exc:
    if trainer.is_world_process_zero():
        send(f"Training crashed: {exc!r}")
    raise

Crashes and hangs need an outside check

An end-of-training hook only covers a clean finish, and try/except only works while the process is alive and moving.

What happensDoes an end hook fire?What catches it
Training finishesYeson_train_end or code after the loop
Python exceptionNotry/except around the training call
Ctrl-CNoexcept BaseException or a finally block
Out-of-memory killer (SIGKILL)NoA check outside the process
Hang or deadlock (stuck data loader or collective call)NoA stall timer outside the process
Machine preempted or lostNoA service that notices updates stopped

When the Linux out-of-memory killer sends SIGKILL, no except, finally or atexit code runs. When a data loader deadlocks or a collective call hangs, the process can sit there without raising anything. Nothing inside a killed or hung process can send a message, so something outside has to notice the silence. The stuck script guide shows how to find where a hung run is waiting.

Keras draws its own progress bar instead of tqdm, so with Keras you get the run link and the alerts, and bars only for loops you wrap in tqdm yourself.

FAQ

Does Keras call on_train_end if training crashes?

No. Keras 3's fit() only calls on_train_end after the epoch loop completes, so an exception skips it. Wrap model.fit() in try/except and send the error from the except branch.

How do I send only one notification in multi-GPU training?

With the Hugging Face Trainer, check state.is_world_process_zero inside your callback before sending. In a plain PyTorch distributed script, send only from rank 0.

How do I get notified if training is killed by the out-of-memory killer?

The out-of-memory killer sends SIGKILL, which ends the process without running except, finally or atexit code. The dead process can't send anything, so you need a watcher in another process or a service that alerts when progress updates stop.

Can I put the final metrics in the notification?

Yes. Format them into the text you send and keep it under Discord's 2,000 characters. Keras passes them in logs, the Hugging Face Trainer has state.global_step, plus state.best_metric when you track the best model, and a PyTorch loop has your own variables.

Does this work in Colab or Jupyter?

Yes, webhook calls and try/except work the same in notebook cells. The kernel keeps running after a cell finishes, so with Runpeek call tqdm_live.end_run("finished") at the end of the cell.

Check on long runs from your phone

Change one import and every tqdm bar prints a link you can open on any device. Get a Discord or Slack message when the run finishes, fails or stalls.

Get a live linkpip install tqdm-live

Sources