Worked examples — TensorBoard - Weights & Biases logging
This page is a workbook. The parent note TensorBoard / W&B logging told you how to call add_scalar and wandb.log. Here we grind through every kind of situation those calls can land you in — the clean cases, the weird edge cases, and the ones that silently break your plots.
Before line one, three plain-word anchors so nothing sneaks in undefined:
Keep that picture: a chart is (tag); a dot is (step, scalar). Almost every bug on this page is a step or a tag doing something unexpected.
The scenario matrix
Every logging situation is one cell below. The worked examples underneath are each labelled with the cell(s) they cover, and together they touch all of them.
| Axis | Cases you must handle |
|---|---|
| Step direction | (A) monotonically increasing step ✓ · (B) step resets / repeats · (C) step goes backwards |
| Step value | (D) step = None (auto) · (E) step is a float (illegal) · (F) two metrics on different step clocks |
| Value health | (G) finite number ✓ · (H) NaN / inf · (I) a tensor with grad instead of a python float |
| Cadence | (J) log every batch · (K) log every N batches (must scale step) |
| Multi-run | (L) comparing runs (same tags, separate dirs) |
| Non-scalar | (M) histogram for gradient diagnosis · (N) hyperparameter sweep summary |
| Word problem | (O) "why does my val curve look shifted left?" real debugging |
| Exam twist | (P) resumed training after a crash — continue the step counter correctly |
Twelve examples below cover cells A–P.
Example 1 — The clean baseline (cells A, G, J)
Forecast: guess the five dots before reading on.
-
List the (step, value) pairs.
(0, 2.30), (1, 1.80), (2, 1.40), (3, 1.15), (4, 1.02). Why this step?epochis the step, so the x-coordinates are exactly0..4. This is the healthy case: step increases by 1, values are finite. -
Check the trend. Each value is smaller than the last, so the line slopes down — training is converging. Why this step? A down-sloping
Loss/trainis the first sanity check TensorBoard gives you; if it slopes up, jump to the gradient-explosion diagnosis (Example 6). -
Compute the total drop. . Why this step? Quantifies "how much did we learn", useful when comparing runs.
Verify: 5 dots, x-values 0,1,2,3,4 (strictly increasing ✓), y strictly decreasing ✓, drop .
Example 2 — Letting the writer auto-step (cell D)
Forecast: if you never say where to draw the dot, where does it land?
-
What "no step" means.
add_scalar's step argument defaults toNone. When it isNone, theSummaryWriteruses an internal counter it increments by 1 on each call for that tag. Why this step? You must know the machine invents a step for you — it does not skip the dot. -
Enumerate the auto steps. Calls 1–5 receive internal steps
0, 1, 2, 3, 4. Why this step? This coincidentally matches "one call per epoch". Auto-stepping is fine only when you log a tag exactly once per iteration. -
The trap. If you log the same tag twice in one iteration (say train then a debug print), auto-step gives them
0,1then2,3— your "epochs" now advance by 2, silently doubling the x-axis. That is why explicitstep(Example 1) is safer. Why this step? Shows the one situation wherestep=Nonebites you, so you know when not to rely on it.
Verify: 5 auto-stepped calls produce steps [0,1,2,3,4]; two-calls-per-iteration produces [0,1,2,3,...] where iteration 's train dot sits at step , not .
Example 3 — Passing a float step (cell E)
Forecast: does TensorBoard round it, crash, or accept the float as-is?
-
What the API expects. The step (called
global_step) is documented as an int. A step is a count of iterations — counts are whole numbers, so a fractional step is meaningless. Why this step? Naming the type contract explains why a float is rejected rather than rounded. -
What actually happens. PyTorch's
SummaryWritercasts the value; a clean float like100.0becomes int100, but a value like100.5is truncated to100, silently colliding with the real step 100. In stricter loggers (andwandb.log(..., step=100.5)) it raises aTypeError. Why this step? You need to know it fails quietly in the worst case — a collision, not an error. -
The fix. Keep step integer by construction: use integer arithmetic
step = epoch * len(train_loader) + batch_idx(Example 5), never division. Why this step? Removes the float at the source instead of relying on the caster.
Verify: int(100.0) == 100 (harmless) but int(100.5) == 100 too (collision with the true step 100); the two distinct floats 100.0 and 100.5 map to the same int step.
Example 4 — Logging every N batches, scaling the step (cells A, K)
The parent's training loop logs every 100 batches. If you naively pass batch_idx as the step, epoch 2's batch 5 collides with epoch 1's batch 5 (same step, different data). The fix is the global step.

epoch by 400 pushes each epoch's dots past the previous block.
Forecast: how many dots total, and what is the largest step?
-
Find the logging batch indices in one epoch.
batch_idx ∈ {0, 100, 200, 300}(those satisfy% 100 == 0and are< 400). Why this step? These are the only batches where theiffires — 4 per epoch. -
Convert to global steps for each epoch. Using :
- Epoch 0:
- Epoch 1:
- Epoch 2:
Why this step? Multiplying
epochbylen(train_loader)shifts each epoch's block past the previous one — look at the figure: the three coloured bands never overlap. This is exactly why we scale the step.
-
Count and find max. dots; largest step . Why this step? Confirms no collisions — 12 distinct increasing x-values.
Verify: 12 dots, all distinct, monotonically increasing, max . Units: step is a count, dimensionless.
Example 5 — Two metrics on different clocks (cell F)
Forecast: true or false — the curves are comparable point-for-point.
-
Identify the two step clocks. Train uses
global_step(0..1199 over 3 epochs); val was stamped with rawepoch(0,1,2). Why this step? Two different x-axes are hiding here. Train's clock ticks 400 times per epoch; the raw-epoch clock ticks once. They are not the same ruler. -
Translate the raw-epoch stamp onto the train clock. If val is stamped with plain
epoch = e, that dot lands at train-step — i.e. val "epoch 1" sits at train step 1, deep inside epoch 0's very first batches. That is the bug we are exposing: raw epoch maps to the wrong place, , instead of where epoch actually ended. Why this step? This makes the mismatch concrete: the naive stamp and the correct stamp are different numbers, and step 3 says exactly what the correct one is. -
The fix. An epoch finishes after its last batch, at global step . So stamp val with that:
writer.add_scalar('Loss/val', val_loss, (epoch+1)*len(train_loader)-1). For epoch 0 that is step — the end of the first epoch, exactly where training was when validation ran. Why this step? The discrepancy between sub-step 2's and this is the whole lesson: raw epoch counts the epoch index, but the chart's x-axis counts batches, and one epoch equals 400 batches. You must convert index → batch-count.
Verify: For epoch index e, corrected val step . Epoch 0 → 399, epoch 1 → 799, epoch 2 → 1199. Strictly increasing, aligned with train's block ends. ✓ See 2.4.7-Gradient-flowand-vanishing-exploding-gradients for why misaligned curves fooled you about overfitting.
Example 6 — Logging a tensor instead of a float (cell I)
Forecast: does it crash, silently leak memory, or log fine?
-
What
lossactually is. It's a 0-dim tensor plus an entire computation graph pinned to it (the graph is whatbackward()walks). Why this step? You must see the hidden baggage: logging the tensor keeps that whole graph alive. -
The consequence. If TensorBoard holds a reference to the graph-bearing tensor, the graph can't be freed → memory grows every step → eventual OOM. Even when it works, you're storing a fat object where a number belongs. Why this step? This is the classic "why does my training slow down and die at epoch 30" bug.
-
The fix:
loss.item()— extract the plain python float, detached from the graph. Why this step?.item()reads the single number and drops the graph. One token, bug gone.
Verify: After loss.item() you hold a float; type(loss.item()) is float → the logged object has no .grad_fn, graph is collectable.
Example 7 — NaN / inf values (cell H)
Forecast: will the loss curve show a giant spike, then what?
-
What
infplots as. TensorBoard drops non-finite points (or shows a gap). You get a curve that stops at step 6. Why this step? A curve that "ends early" is the signature of a divergence, not a bug in logging. -
Trace the story. Finite losses
2.3, 2.1, ... (step 0–6), theninf(step 7), thenNaN(step 8+). Once aNaNappears, gradients areNaN, weights becomeNaN, everything downstream isNaN. Why this step?NaNis contagious: . The disease never heals on its own. -
Diagnose the cause with the gradient-norm log. The
GradNormscalar from the parent's loop would spike toward one step before the loss does. That's your early-warning line. Why this step? Exploding gradients precede exploding loss — see 2.4.7-Gradient-flowand-vanishing-exploding-gradients.
Verify: In floating point, inf + 1 == inf, inf - inf is NaN, and NaN == NaN is False (that last fact is how code detects it: x != x is True only for NaN).
Example 8 — Histogram for a vanishing gradient (cell M)

Forecast: which layer has stopped learning — first or last?
-
Read a histogram. A histogram is a spread of numbers: wide = many different gradient magnitudes; a thin spike at 0 = almost all gradients ≈ 0. Why this step? One scalar can't show this — you need the whole distribution, which is why the histogram tool exists (parent note, "Why each type").
-
Interpret the collapse. Layer 1's gradients ≈ 0 means its weights barely update → it has effectively stopped learning. This is the vanishing gradient signature. Why this step? The figure shows the front-layer distribution squeezing to zero while the back layer stays fat — exactly the depth-dependent decay of gradients.
-
What to check next. Activation function (sigmoid/tanh saturate), initialization, or add residual connections. Why this step? Ties the visual straight to a fix. See 2.4.7-Gradient-flowand-vanishing-exploding-gradients.
Verify: If gradient std shrinks by factor 0.5 per layer across 6 layers, the front layer's std is of the back layer's — a 64× collapse, matching a near-spike.
Example 9 — Comparing runs (cell L)
Forecast: guess the winning lr before computing anything.
-
Why they overlay. Same tag (
Accuracy/val) across sibling directories → TensorBoard treats each directory as a separate run and draws both on the shared chart, colour-coded. Why this step? This is the comparison mechanism — same tag, different dir. Rename the tag and they'd split into two charts (usually not what you want). -
Pick the winner. , so Run A (lr = 0.01) wins by accuracy. Why this step? Direct read-off; the gap quantifies how much the smaller lr helped here.
-
Generalise. This is the seed of a hyperparameter sweep — see 4.1.5-Hyperparameter-tuning and record the winner for 5.2.3-Model-versioning.
Verify: winner is Run A; margin (6 percentage points). ✓.
Example 10 — Summarising a hyperparameter sweep (cell N)
Forecast: guess whether "bigger lr = better" or there's a sweet spot.
-
What
add_hparamslogs. Unlikeadd_scalar(a curve over steps),add_hparamslogs one summary row per run: the hyperparameters as inputs and the final metrics as outputs. TensorBoard's HPARAMS tab shows a sortable table and a parallel-coordinates plot. Why this step? A sweep summary answers a different question than a training curve — "which config won?", not "how did this one config converge?" — so it uses a different call. -
Read off the best row. Sort by
val_acc: (lr=0.01) is highest. So the winning lr is , not the largest. Why this step? Sorting is exactly what the HPARAMS table does for you; we do it by hand here. -
See the shape. Accuracy rises
0.90 → 0.94then falls0.88 → 0.10: an inverted-U. Too small = slow learning; too large = divergence (that0.10is chance level on 10 classes). The peak is the sweet spot. Why this step? Recognising the inverted-U is the whole point of a sweep — the optimum is interior, never at an extreme. Feeds directly into 4.1.5-Hyperparameter-tuning.
Verify: best val_acc at lr ; sequence [0.90,0.94,0.88,0.10] rises then falls (inverted-U), and equals random-guess accuracy on 10 classes.
Example 11 — Word problem: the shifted val curve (cells O, F)
Forecast: is validation really "ahead", or is a step off by one?
-
Restate as steps. Train logs at global steps ending each epoch: . Val is logged at
epoch= — but the teammate multiplied bylen(train_loader)using the next epoch, landing val at , one batch right of train... yet the plot shows left. Why this step? Turning a vague complaint into concrete step numbers is the whole skill. -
Find the real mistake. They logged val before incrementing epoch, so val for "epoch 1's data" was stamped with step — pushing every val point one whole epoch left of the matching train point. Why this step? Off-by-one in the step formula is the single most common cause of "shifted" curves.
-
The fix. Stamp val with the end-of-epoch global step: (same fix as Example 5). Why this step? Re-anchors val to where training actually finished that epoch.
Verify: wrong stamp for epoch : → epoch 1 gives 400. Correct stamp: → epoch 1 gives 799. Difference ≈ one epoch of batches. That 399-step gap is the visible "shift". ✓
Example 12 — Exam twist: resuming after a crash (cells B, C, P)
Forecast: will the new run's steps go backwards?
-
Trace the naive restart. New loop computes
global_step = epoch*400 + batch_idxstarting at . But the event file already has data up to step . New points land at — behind existing ones. Why this step? Steps that go backwards (case C) make TensorBoard draw a curve that loops back on itself — a tell-tale zig-zag. -
Why it's bad. Two different weight-states now share step (pre-crash and post-resume). The chart is meaningless. Why this step? Same step, different data = collision, exactly the sin Example 4 fought to avoid.
-
Correct resume. Save the last step to the checkpoint; on reload set
start_step = 850and computeglobal_step = start_step + epoch*400 + batch_idx. The first post-resume dot lands at (or the next logged multiple, ), always greater than everything before it, so the step axis stays monotonically increasing across the crash — the curve continues instead of rewinding. Why this step? Preserving a single ever-increasing step counter is what makes a resumed run readable; storestart_stepnext to the checkpoint so it survives the crash — a 5.2.3-Model-versioning concern.
Verify: first post-resume logged step (next log at ), all except the boundary equal — strictly increasing beyond it ✓.
Recall
Recall The step is the ___ coordinate of a logged dot; the scalar is the ___ .
Step is the x-coordinate; scalar is the y (height).
Recall Why pass
loss.item() instead of loss to add_scalar?
.item() extracts a plain float and drops the autograd graph, avoiding a memory leak.
Recall You log train every batch and val every epoch. What must you do so the curves line up in time?
Stamp val with the end-of-epoch global step , not the raw epoch.
Recall On a
Loss/train chart the curve simply stops at step 6. Most likely cause?
Divergence — an inf/NaN appeared at step 7 and TensorBoard drops non-finite points.
Recall What does
add_hparams log that add_scalar does not?
One summary row per run (hyperparameters in, final metrics out) — not a curve over steps.
Recall You pass
step=100.5 (a float). What is the danger?
It is cast to int 100, silently colliding with the true step 100 (or raises TypeError in stricter loggers).
Global step formula
Two runs overlay on one chart when
NaN detection trick in code
x != x is True only when x is NaN.Auto-step (step=None) increments by
add_scalar call for that tag.