At a glance
- Keras:
Callback.on_train_end(logs)receives the last epoch's metrics. - Hugging Face:
TrainerCallback.on_train_end; send only whenstate.is_world_process_zero. - An exception skips both callbacks, so wrap
fit()ortrain()intry/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.
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.
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}")
raisePyTorch: 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.
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_stepstate.best_metricandstate.best_model_checkpoint, filled in only when you track the best model (metric_for_best_modelorload_best_model_at_end=True) and evaluation runsstate.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().
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}")
raiseCrashes 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 happens | Does an end hook fire? | What catches it |
|---|---|---|
| Training finishes | Yes | on_train_end or code after the loop |
| Python exception | No | try/except around the training call |
| Ctrl-C | No | except BaseException or a finally block |
| Out-of-memory killer (SIGKILL) | No | A check outside the process |
| Hang or deadlock (stuck data loader or collective call) | No | A stall timer outside the process |
| Machine preempted or lost | No | A 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.
pip install tqdm-liveSources
- Keras: Callbacks API keras.io
- Keras: Writing your own callbacks keras.io
- Hugging Face: Callbacks huggingface.co
- PyPI: transformers pypi.org
- PyPI: keras pypi.org
- Python docs: atexit docs.python.org
- Discord developer docs: Execute Webhook docs.discord.com