Explainable AI (XAI) Toolkit Expansion for donkeycar - #1247
Open
YashTandon05 wants to merge 1 commit into
Open
Conversation
…r and a new offline viewer to see attention visualization maps.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Explainable AI (XAI) Toolkit
Overview
Stock Donkeycar's deep-learning autopilot gives you a steering/throttle number and nothing else — you can't tell how sure the model was, or why it steered the way it did, without guessing. This fork adds an explainability and uncertainty toolkit on top of the existing pilot, built from four independent pieces:
Alongside the live signals, there's an offline analysis viewer: point it at a recorded drive and a model, and it produces a frame-by-frame, browser-based playback showing where the model was looking and why it was or wasn't confident, using four different visual attribution techniques (Grad-CAM, Grad-CAM++, Integrated Gradients, and pixel-gradient saliency).
All three live signals — and therefore the throttle scaling built on top of them — share one dependency: a calibration file (
<model>.calib.json) that turns each raw internal number into a 0-100% score that's meaningful for your specific model and your specific training data. Without it there is no percentage to display or act on, so the dashboard panels stay hidden and throttle scaling passes throttle through untouched. The offline viewer is the exception: it runs without a calibration too, it just falls back to plotting raw variance instead of the 0-100% lines. If your model was trained with anyAUGMENTATIONSturned on, calibration needs to know about that too, or the novelty signal will misfire — covered in detail in §2.8.This document's Section 1 is a usage walkthrough: what to turn on, in what order, and what you'll see. Section 2 is a technical explanation of how each signal actually works.
1. Usage
1.1 The pipeline, at a glance
Calibration sits in the middle because every percentage downstream — the live dashboard panels, throttle scaling, and the offline viewer's confidence/novelty/stability graph — depends on it existing. (The offline viewer still runs without one; it just plots raw variance instead.)
1.2 Recording and training
No changes here — record a tub and train exactly as stock Donkeycar describes (
donkey train). The XAI toolkit only reads the model file afterwards; it doesn't change how training itself works. One thing to know: MC-Dropout confidence (and, by extension, most of this toolkit) currently only supports thelinearmodel type, since it relies on theDropoutlayers already present in that architecture staying active at inference time — see §2.2 for why.Recording an autopilot drive (for offline review, not for training). By default Donkeycar only records frames while you're driving in
'user'(manual) mode — switching into autopilot stops recording, even if the recording toggle is on. If you want to feed an actual autonomous run into this toolkit's offline viewer (§1.6), for example to see what the model was looking at right before a near-miss, set the stock Donkeycar option inmyconfig.py:This is stock Donkeycar behaviour and this toolkit does not change it: autopilot frames go into the same tub as your manual frames, so the standard warning still applies — don't train on a tub recorded this way without filtering it first. The XAI columns (
pilot/confidence,pilot/novelty, …) are written alongside every frame, but only autopilot frames actually carry values; manual frames leave them empty, because the signals run under Donkeycar'srun_pilotcondition. The offline tool handles that mix explicitly rather than guessing — see the note on unscored frames in §1.6.1.3 Calibration
Calibration replays a tub of "known good" driving through the model once and records how the raw signals behave on data the model actually learned from — this becomes the yardstick every live percentage is measured against. Nothing lights up on the dashboard until this has run once for a given model.
Option A — automatic, at the end of training. In
myconfig.py:This adds one extra replay pass over your training tubs right after
donkey trainfinishes, and saves<model>.calib.jsonnext to the model automatically. Off by default because it adds time to every training run.Option B — manual, from the command line (needed if you trained before turning on auto-calibrate, or want to recalibrate later):
Point
--tubat the same data the model was actually trained on — calibration is meant to answer "what does normal look like to this model," and calibrating against a different drive (e.g. a test/inference tub) silently defeats the whole signal. The offline GUI launcher (§1.6) tries to auto-detect this for you from Donkeycar's own training record; the CLI here has no such lookup, so it's on you to point--tubcorrectly.One calibration run covers all three signals at once — confidence, novelty and TTA stability are always all written into the file, whether or not you currently have them switched on for driving. So turning
XAI_TTA_ENABLED(or any other signal) on later just works; you don't need to recalibrate to "add" it.When to recalibrate:
AUGMENTATIONSin a meaningful way (see §2.8).XAI_*_INTERVALvalue. Calibration replays at whatever cadence the interval was set to at calibration time (see §2.10); tightening or relaxing an interval afterward means the live update rhythm no longer matches what the anchors were fit against.On config loading: all three entry points (
mc_calibrate,gradcam_uncertainty, the GUI launcher) resolve--configthe same way — your./config.pyif you're in a car directory, otherwise the bundled template — and in every case they also apply the siblingmyconfig.pyon top. That last part matters: Donkeycar's ownload_config()findsmyconfig.pyby string-replacing"config.py"in the path, which silently does nothing when the base file is named anything else (such as the bundledcfg_complete.py), so your real overrides would be dropped and the whole calibration built against template defaults.On image size: calibration and offline analysis both read the input size directly from the model file, so a model trained at a different resolution than your current config still works — you'll just see a warning saying which size was used, and
IMAGE_W/IMAGE_Hare taken from the model. If that warning looks wrong, point--configat theconfig.pythe model was actually trained with. Because the model file is treated as the authority there, resolution is deliberately left out of the preprocessing-sidecar comparison described in §2.9 — the crop geometry and transformation list, which a model file cannot self-describe, are still checked strictly.1.4 Driving live with the signals on
Each signal is an independent switch in
myconfig.py— turn on only what you want:Once enabled and calibrated, driving with the web dashboard open shows a panel per signal:
(Novelty's color scale is intentionally flipped from the other two — a high number there means "unfamiliar," which is bad, whereas a high number for confidence/stability is good.) Each panel has a "What does this mean? ▾" toggle with a plain-English explanation, and the confidence panel is explicit that it's "[r]elative to this model's own baseline, not a calibrated probability" — see §2.3 for what that means concretely.
If a signal is enabled but not yet calibrated, its panel simply never appears — instead, on connecting to the dashboard you'll see a dismissible amber banner headed "Calibration warnings: these signals won't show a percentage, or may be reading against the wrong baseline," listing exactly which signal(s) are affected and the exact
mc_calibratecommand to fix each. Go run calibration (§1.3) and reconnect.Panels for calibrated signals appear as soon as the dashboard connects, including on a reload or a second browser: the server sends each new client the current value up front rather than waiting for the number to change.
Pi / performance tuning: each signal has its own
XAI_*_INTERVAL(seconds;0.0= every frame, no rate-limiting at all). Out of the box these are already relaxed rather than0.0— confidence and novelty default to0.3(~3 updates/sec), TTA to0.5(~2 updates/sec) since it's the priciest signal (it runsXAI_TTA_SAMPLESextra forward passes per update). Confidence never costs steering responsiveness either way — the car still steers on a cheap single-pass inference every frame regardless of its interval, only the confidence number updates less often. Novelty and TTA are pure observers, so a held frame between updates costs zero forward passes. Raise any of these further if a Raspberry Pi is still struggling to keep up withDRIVE_LOOP_HZ— TTA is the first knob to turn. See §1.5 for how a longer interval trades off against throttle-scaling reaction time.1.5 Confidence-aware throttle scaling
An optional fourth switch that actually acts on the three signals above instead of just displaying them:
With this on, whichever of confidence/novelty/stability is enabled gets combined: if any signal crosses its "reduced" threshold, throttle is scaled down (linearly, down to
XAI_THROTTLE_MIN_SCALE, default0.4= never below 40% of the commanded throttle from a single signal); if any signal is past its "critical" threshold continuously forXAI_THROTTLE_STOP_DURATIONseconds (default1.0), throttle is forced to zero. It only ever touches throttle — steering is never modified. Turning this on without enabling at least one of the three signals just logs a warning and does nothing (there's nothing to scale on).It runs after
AI_LAUNCH_*, on purpose. Donkeycar's stock AI-launch boost (AI_LAUNCH_DURATION,AI_LAUNCH_THROTTLE) replacespilot/throttlewith a fixed value rather than modulating it, so throttle scaling is wired in after the launch boost in the vehicle loop, not before — otherwise a critical signal (including the forced stop) would have no effect for the firstAI_LAUNCH_DURATIONseconds after switching into autopilot, exactly the window the car is least predictable in. With this ordering, whichever valuepilot/throttlecurrently holds — launch-boosted or not — still gets scaled or stopped if a signal says so.Reaction-time tradeoff with the interval knobs (§1.4): novelty and TTA are only ever as fresh as their
XAI_*_INTERVAL— a sudden problem won't be seen until the next scheduled update, up to that many seconds later, andXAI_THROTTLE_STOP_DURATIONthen still needs to see it stay critical continuously before forcing a stop. At the defaults, that's roughly up to0.3 + 1.0 = 1.3sfor novelty-triggered or0.5 + 1.0 = 1.5sfor TTA-triggered stops, worst case. That's fine if you're using these signals for dashboard/monitoring, but if you're relying onXAI_THROTTLE_SCALING_ENABLEDas a real-time safety net, consider loweringXAI_NOVELTY_INTERVAL/XAI_TTA_INTERVALback toward0.0so the car reacts faster, at the cost of more compute per frame.1.6 Offline analysis — the GUI launcher (recommended)
The easiest way to review a drive is the browser-based launcher, which wraps the CLI tool below so you never need to remember its flags. From your car's directory (
donkey createcarinstalls this script for you):This starts a local web server and prints a URL to open. You'll see a "Run Explainability Analysis" form:
data/mytub(autocompletes from tubs found underdata/)models/mypilot.h5(autocompletes frommodels/) — once selected, a hint below the field shows what pipeline this model was actually trained with (e.g.Trained with: CROP, CANNY), read straight from the model's own.preprocessing.jsonsidecar (§2.9)TRANSFORMATIONS/POST_TRANSFORMATIONSdon't match what the selected model was actually trained with — since a mismatch would silently compute every heat map on pixels the model never saw. Only fill this in to deliberately analyse a model under a different pipeline than it trained with (an intentional what-if); doing so replacesPOST_TRANSFORMATIONSfor this run only and skips that check, since you're now doing it on purpose500or1000-2000instead of the whole tubmc_calibratestep. Override it by hand any time; your choice is never clobbered afterwardClick Run Analysis and a progress readout tracks the current stage (variance → novelty → Grad-CAM). If auto-calibration had to fall back to using the analysis tub itself (because no training-tub record could be found), you'll see an amber warning and the page will not auto-continue — you have to click Continue to viewer explicitly, so a possibly-wrong calibration baseline can't slip by unnoticed. Otherwise it jumps to the viewer automatically when done.
Unscored frames. A tub recorded with
RECORD_DURING_AI(§1.2) contains manual frames that carry no XAI values, because the signals only run while the autopilot does. Where a tub already has logged values the tool reuses them rather than recomputing, and frames without one are treated as unknown, not as zero — they stay in the timeline (drawn as a gap in the graph, shown asvariance n/a), but they're excluded from "top-K most uncertain" and "percentile ≥" selection, and a log line reports how many were skipped. Zero would mean lowest possible variance, i.e. maximum confidence, which would rank exactly the frames nobody measured as the most certain in the drive. If you want those frames analysed anyway, use all frames, which ignores the ranking entirely.The viewer, once open, shows the current frame plus a chosen overlay layer:
TRANSFORMATIONS/POST_TRANSFORMATIONS(crop, edge detection, colour conversion). This is the actual input every heat map above was computed against; only shown when the pipeline changes something (a no-op pipeline would make this identical to "Just the camera")A short plain-language explanation for whichever layer is selected is always shown underneath the dropdown — no separate "advanced mode" to find it in. When a transformation changes the frame's geometry (see §2.9 — this is now the normal case for
CROP), a "draw on model input" checkbox appears next to the layer dropdown: unticked (default) draws every heat map projected back onto the raw camera frame, so the scene stays recognisable; ticked draws them directly on the transformed frame instead, with no projection needed since that's the exact input the maps were computed from. Playback controls: Space/arrow keys or the play button at 2/5/10 fps, a scrub bar, and click-to-jump on the confidence graph below the image. The graph plots confidence/novelty/TTA-stability as separate, independently toggleable lines (checkboxes above the graph — whichever signals you actually calibrated/enabled), with the frames that got a full image analysis marked along the top edge. A "What do confidence / novelty / stability / variance mean?" link expands a glossary covering all of these terms in plain language, always available with one click.1.7 Offline analysis — CLI (advanced / scripted use)
Equivalent to the launcher's form, useful for automation or a headless box:
Then view the result (with or without the launcher's form):
The CLI enforces the same preprocessing-match check as the launcher (§2.9) — it refuses to run if
--config'sTRANSFORMATIONS/POST_TRANSFORMATIONSdon't match the model's saved training pipeline — but has no equivalent of the launcher's override field, so point--configat the actual config the model was trained with rather than trying to work around a mismatch here.1.8 Config reference
Confidence (MC-Dropout)
Novelty / OOD detection
TTA stability
Throttle scaling
Offline analysis / calibration extras
1.9 Things to watch out for
.h5model, not a.tfliteexport — TFLite conversion bakes dropout out, so there's nothing left to disagree.linearmodel type only — this is enforced, not just advised. Confidence and TTA read the model's output tensors as plain steering/throttle numbers, which is only true of thelineararchitecture; on acategorical,behavior,imuorinferredpilot (or withTRAIN_LOCALIZER = True) those outputs are bin vectors or a differently-shaped list. If either signal is switched on for such a model the vehicle logs a warning and runs without them, rather than feeding the car a meaningless average of a softmax. Novelty is unaffected and still works on any model type — it never touches your pilot, only its own encoder.RECORD_DURING_AI(§1.2) is stock behaviour, unchanged by this toolkit: autopilot frames land in the same tub as manual ones, so the usual "don't train on this" caveat still applies. Such a tub is a mix of scored (autopilot) and unscored (manual) frames; §1.6 covers how the offline tool treats the unscored ones.TRANSFORMATIONS/POST_TRANSFORMATIONSconfig now stops things, rather than silently misbehaving. Training writes a<model>.preprocessing.jsonsidecar recording the exact pipeline it used; both live driving and offline analysis (§1.6/§1.7) read it back and refuse to proceed if the current config doesn't match. Two gaps worth knowing: the plainmc_calibrateCLI (§1.3) does not run this check itself — it's enforced at drive-time and analysis-time, not at calibration-time — so it's still on you to point--tub/--configat the right pair when calibrating by hand; and the sidecar carries no schema version, so a sidecar written by an older build is compared only on the fields both versions happen to share (you'll get a "predates tracking …" warning naming what couldn't be verified).2. Technical deep dive
2.1 Why explainability and uncertainty matter here
A CNN driving policy is a black box mapping camera pixels to a steering angle; by default it gives you the same confident-looking single number whether the road ahead is a textbook straightaway or something the model has never seen. For a small robot that can hit a wall or a person, being able to ask "how sure are you" and "why did you decide that" is the difference between a system you can trust incrementally and one you can only evaluate by watching it crash. Everything in this toolkit is one of two things: a way of estimating uncertainty (confidence, novelty, TTA stability — "should we trust this frame's answer?"), or a way of producing visual attribution (Grad-CAM, Grad-CAM++, Integrated Gradients, saliency — "what part of the image caused this answer?").
2.2 Dropout and Monte Carlo Dropout (confidence)
Dropout, as used during normal training, randomly zeroes out a fraction of a layer's neurons on every training step (Donkeycar's default
lineararchitecture hasDropout(0.2)after several convolutional and dense layers — seedonkeycar/parts/keras.py). This forces the network to not rely too heavily on any single neuron (since it might be zeroed out next step), which is a well-known technique for reducing overfitting. Normally, once training finishes, dropout is switched off for inference — every neuron participates, and you get one deterministic prediction.Monte Carlo Dropout (Gal & Ghahramani, 2016) is the trick of leaving dropout switched on at inference time and running the same input through the network multiple times. Since a different random set of neurons is zeroed out on each pass, each pass is effectively asking a slightly different, randomly-thinned "sub-network" for its opinion. If the input is something the model is confident about, most sub-networks should agree on roughly the same steering angle regardless of which neurons got dropped — the underlying feature is redundantly represented. If the input is ambiguous or unfamiliar, different sub-networks latch onto different (possibly spurious) cues and disagree more. Confidence in this toolkit is literally the inverse of that disagreement: run N stochastic passes, measure the variance of the resulting steering angle across them, and treat high variance as low confidence.
Implementation detail worth knowing: running N passes sequentially is too slow for a 20 Hz drive loop (roughly 165 ms for 15 passes on a desktop in early testing). Instead, the same image is replicated N times into one batch and passed through the model in a single batched call — the network still applies an independent random dropout mask to each element of the batch, so this is mathematically the same N independent stochastic passes, just computed in parallel (roughly 29 ms for the same 15 passes). See
donkeycar/parts/mc_dropout.py.The raw variance is smoothed frame-to-frame with an exponential moving average (
XAI_CONFIDENCE_ALPHA) so the displayed number doesn't flicker, then converted into the 0-100% shown on the dashboard via calibration (§2.3).2.3 Calibration mechanics
A raw MC-Dropout variance number (or a raw Mahalanobis distance, for novelty) is meaningless on its own — its scale depends on this specific model's weights, this specific dataset, even the random dropout masks drawn. Calibration (
mc_calibrate.py) solves this by replaying a known tub through the model once, collecting the raw variance for every frame, and computing percentiles (e.g. the 50th/80th/97th) of that distribution. A monotonic piecewise-linear map is then built from "raw variance value" to "0-100% score," anchored at those percentiles, and saved to<model>.calib.json. At drive time, a new raw variance is looked up against this same map to produce the displayed percentage.The important consequence: a confidence score is a statement about where this frame's variance falls relative to your own training data's variance distribution — not a calibrated probability of correctness. This is also why the dashboard explicitly disclaims it as "relative to this model's own baseline." It also means the percentile anchors are inherently relative, not absolute: by construction, roughly 20% of any tub replayed against an 80th-percentile-derived threshold — even the original training data itself — will read above that threshold. A "low confidence" reading doesn't necessarily mean something is objectively rare; it means it's rarer than most of what this model saw during calibration.
Novelty and TTA stability are calibrated the same way, with their own percentile anchors, just walking in the opposite direction (ascending for novelty — higher raw distance is worse — vs. descending for confidence and TTA stability, where higher is better).
2.4 Feature-space novelty / out-of-distribution detection
The goal here is different from confidence: instead of "do sub-networks of this model agree," it's "does this frame look anything like what the model was trained on at all" — catching genuine out-of-distribution (OOD) input like the car leaving the track entirely.
The underlying statistic is Mahalanobis distance: instead of plain Euclidean distance from a "typical" feature vector, it's a distance that accounts for how much each dimension is expected to vary — a value 3 standard deviations away from the mean on a dimension that's normally almost constant is treated as far more surprising than the same absolute distance on a dimension that's normally noisy. Concretely: fit a Gaussian to a set of feature vectors extracted from calibration frames (mean + per-dimension variance — a diagonal covariance approximation, i.e. we don't model correlations between dimensions, which is far cheaper to compute and invert than a full covariance matrix), then for a new frame's feature vector, compute how many (variance-normalized) standard deviations away it falls.
The interesting part of this feature is which features that distance is measured in — and getting it wrong the first time was a genuine, real finding. The first implementation measured distance in the driving model's own penultimate layer (
dense_2, 50-dimensional). This didn't work: a model trained only to predict steering angle has every incentive to discard anything not needed for that task — texture, color, semantic content — so grass, carpet and open track all collapse to nearly the samedense_2vector, because they all mean "drive straight." Measuring against real training data, grass scored as more familiar than a legitimate on-track frame in some cases (a "grass is 90%+ confident" result that was genuinely reproduced, not a hypothetical). The fix: extract features from a generic, frozen ImageNet-pretrained encoder (MobileNetV2 by default,XAI_NOVELTY_ENCODER) instead of the task-specific steering model. A network trained on ImageNet's thousand object categories has to keep general visual information (texture, color, shape) around, so grass and track genuinely land in different regions of that feature space — the same math, applied to a feature space that hasn't collapsed away the information needed to tell them apart, correctly separated grass/carpet (scoring as clearly unfamiliar) from track frames.The live detector (
donkeycar/parts/novelty.py) runs one extra deterministic forward pass through this encoder per update, computes the Mahalanobis distance against the calibrated Gaussian, smooths it with an EMA, and maps it to 0-100% the same way as confidence (just ascending instead of descending). The offline viewer's per-location novelty overlay uses the very same encoder weights, just without the final averaging step, so the heat map and the percentage beside it live in the same feature space and answer the same question — a large improvement on the earlier version, where the map came from the steering model'sconv2d_5and the percentage from the encoder, i.e. two unrelated spaces.They are not, however, the same measurement, and it's worth being precise about that: the live pooled encoder squashes the frame to a 128×128 square (fine, since every location is averaged together anyway), while the offline map runs at a larger aspect-preserving input (
XAI_NOVELTY_SPATIAL_INPUT, default 224 → roughly a 7×12 grid on a 16:9 frame) so the heat map isn't stretched when drawn back over the frame. Different input geometry means different feature vectors, which is why calibration fits and stores them as two separate blocks (novelty_oodandnovelty_ood_spatial) with their own Gaussians and anchors. Expect the map and the percentage to agree in character — the same regions look unfamiliar — not to agree numerically.2.5 Test-Time Augmentation (TTA) stability
A third, independent question: if the same real-world scene were lit very slightly differently, would the model give a different answer? TTA stability answers this directly rather than by proxy: it takes the current frame, generates
XAI_TTA_SAMPLEScopies with small random photometric-only jitter (brightness/contrast/gamma/noise — deliberately not geometric changes like crop or rotation, since those would actually change what the correct steering angle is), batches them through the model in one deterministic forward pass (dropout off, unlike MC-Dropout), and measures the variance of the resulting steering angle across that batch. High variance means the model's answer is fragile to lighting noise that shouldn't matter; low variance means it's robust to it.This is a genuinely different signal from MC-Dropout confidence, not a duplicate of it — MC-Dropout asks "do many random internal sub-networks of this one exact image agree," while TTA asks "does the same sub-network agree with itself across slightly different lightings of the same scene." Measuring both against the same real footage showed a correlation of only about 0.19 between their raw variances — confirming they pick up on different kinds of trouble.
2.6 Confidence-aware throttle scaling
ThrottleScalertakes whichever of the three 0-100% signals are enabled and, for each one independently, computes a scale factor between the configured min-scale and1.0: full throttle above the signal's "reduced" threshold, linearly interpolated down toXAI_THROTTLE_MIN_SCALEbetween "reduced" and "critical," and held at the minimum below "critical." Confidence and TTA stability are descending (high value = good, so the scale shrinks as the value drops); novelty is ascending (high value = bad, so the scale shrinks as the value rises) — both use the same underlying interpolation, just mirrored.The final throttle scale is the minimum across every enabled signal's scale — i.e. whichever signal currently looks worst wins, a direct implementation of "slow down if any signal indicates trouble," rather than averaging signals together (which could let one bad signal be diluted by two fine ones). If any signal has been continuously in its critical range for
XAI_THROTTLE_STOP_DURATIONseconds, throttle is forced to exactly zero; the timer is shared across signals, so a car that goes from "novelty-critical" straight into "confidence-critical" without recovering in between still counts as one continuous critical episode, not two resets. Steering is never touched by this part.2.7 Visual attribution: four ways to ask "why"
The offline viewer computes four different attribution maps per analysed frame, because they answer subtly different questions and none of them is uniquely "correct":
Grad-CAM — computes the gradient of the (summed) steering output with respect to the activations of a late convolutional layer (
conv2d_5), uses those gradients to weight each channel of that layer's feature map, sums the weighted channels, and applies a ReLU (keeping only the parts that positively influenced the output). Because it operates on a late-stage conv layer, the result is naturally coarse — a small grid (e.g. 8×13) upsampled back to image size — showing roughly where in the image mattered, not exact pixels. In this toolkit, Grad-CAM is combined with MC-Dropout: it's computed once per dropout sub-network in the same batch, and the mean map across sub-networks becomes the "attention" overlay while the pixel-wise variance across those same maps becomes the "uncertainty" overlay — literally, "where did the model look" and "where did different sub-networks disagree about where to look."Grad-CAM++ — a refinement of Grad-CAM's channel weighting that accounts for a feature appearing in multiple locations at once (plain Grad-CAM's simple gradient-averaging weight can under-represent that case). The textbook derivation assumes a softmax classification output; since this is a regression output (a single steering value), this toolkit uses a practical approximation of the pixel-wise weighting derived from first- and second-order gradient terms (
alpha = g² / (2g² + (ΣA)g³)) rather than the classification-specific formula. In practice it tends to produce sharper, more spatially precise blobs than plain Grad-CAM.Integrated Gradients (IG) — instead of looking at gradients at the actual input alone (which can saturate — a very confidently-classified pixel can have a near-zero local gradient even though it clearly mattered), IG integrates the gradient along a straight-line path from a neutral baseline (a black image) to the real input, in
XAI_IG_STEPSdiscrete steps (a Riemann-sum approximation of the integral), and multiplies the averaged path gradient by(input - baseline). This satisfies a completeness property that plain gradients don't: the attributions sum up to exactly the difference between the model's output on the real image and on the baseline. Because it operates directly on input pixels rather than a coarse conv layer, its maps are full resolution.Vanilla gradient saliency — the simplest and cheapest: one backward pass, the gradient of the output directly with respect to every input pixel. (One implementation wrinkle: a saturating output activation would clip gradients right where they matter most, so the output layers are linearized first. For the
lineararchitecture they already are, so this is detected and skipped entirely; where it is needed — acategoricalmodel's softmax, viadonkey makemovie --salient— it's applied to a reloaded copy, leaving the pilot you're still driving with untouched.) Like IG, it's full-resolution, but with no baseline/integration step, so it's noisier and more speckled.Why look at all four: the two conv-layer-based methods (Grad-CAM, Grad-CAM++) give smooth, coarse, easy-to-read localization; the two pixel-gradient methods (saliency, IG) give precise-but-noisier, exact per-pixel attribution. On a real test frame containing a pedestrian, Grad-CAM/++ showed two smooth blobs on the ground near the pedestrian, while saliency showed many small, scattered points across the pedestrian, nearby pillars, and background architecture — a genuinely different, complementary answer, not a redundant one. Relying on only one technique risks mistaking "coarse but clean" for "the whole picture," or "precise but noisy" for "definitely irrelevant."
2.8 How augmentations feed into calibration
Calibration's job is to define "what does normal training data look like" — which becomes the novelty detector's baseline. If a model is trained with, say,
AUGMENTATIONS = ['BRIGHTNESS', 'BLUR'](so it has learned to drive correctly through varying exposure and softer focus), but calibration is only ever shown clean, un-augmented frames, the novelty baseline never learns that those conditions are "normal" — so the very conditions the model was made robust to would get flagged as unfamiliar and throttle down live, exactly backwards from the intent.This was measured directly, not just reasoned about: fitting the novelty baseline on clean frames only, then scoring augmented-but-otherwise-normal frames against it, 79% crossed the "reduced" novelty threshold (vs. 38% for clean frames) and 50% crossed "critical" (vs. 10%). A model trained to handle varying lighting would have been throttled down by its own safety feature the moment it encountered it.
The fix, in
mc_calibrate.py: whenAUGMENTATIONSis non-empty andXAI_CALIBRATE_WITH_AUGMENTATIONSis on (the default), calibration pushes a strided subset of frames (capped byXAI_CALIBRATE_AUG_MAX_SAMPLES) through the sameImageAugmentationpipeline used in training,XAI_CALIBRATE_AUG_PASSEStimes each (re-randomized every pass), and pools those augmented features into the novelty baseline alongside the clean ones. The resultingcalib.jsoncarries a small"augmentation"provenance block recording whether this happened and with what settings, so it's inspectable after the fact.This is scoped to novelty only — not confidence or TTA. Both of those carry an exponential moving average over a time-ordered sequence of frames; splicing randomly-augmented frames into that sequence would corrupt the smoothing (a sudden synthetic brightness jump between two real consecutive frames isn't something either signal is designed to interpret). Novelty's calibration, by contrast, is a plain unordered percentile fit over a feature distribution, so mixing in augmented samples is safe and (per the measurement above) necessary.
2.9 How transformations affect the signals
TRANSFORMATIONS(crop, trapezoidal mask, colour-space conversion, high-pass filters) are different from augmentations in one way that matters enormously here: they apply at both training and inference. The model is only ever shown transformed pixels, so anything measuring the model's behaviour must be measured on transformed pixels too, or it's describing a situation that never actually occurs while driving.That creates a split, and the toolkit handles the two halves differently:
TRANSFORMATIONSpipeline training uses, in the same order (transform → augment → post-transform).The magnitude here is not subtle. Measured on real frames with a modest crop (30 of 108 rows), feeding novelty the transformed frame instead of the raw one moved its score from 4.9% to 97.8% — normal driving reading as maximally unfamiliar. Whichever input you choose, calibration and live driving must use the same one; the toolkit keeps them in step.
Both
CROPandTRAPEZEare masking transforms: they blank out pixels outside the region of interest but leave the image at its original dimensions. That makes a mismatch the dangerous case — same dimensions, no shape error, just silently wrong thresholds — so it is caught directly instead: training saves a<model>.preprocessing.jsonsidecar recording the exactTRANSFORMATIONS/POST_TRANSFORMATIONS/ROI_CROP_*pipeline it was trained with, and both live driving and offline analysis refuse to run if the current config doesn't match it (§1.9). A calibration also records which transformations built it and the dashboard raises a "Calibration stale" warning if that's drifted, independent of this check.In the offline viewer, what you see by default is the raw camera frame with heat maps drawn on it — since every available transform preserves the frame's geometry, that mapping is direct. You can also switch to the "What the model actually sees (transformed)" layer to see the transformed frame directly, and tick "draw on model input" to see the heat maps drawn on it instead (§1.6).
2.10 Known limitations
_build_novelty_blocktook its p50/p80/p97 from the raw per-frame Mahalanobis distances of the calibration frames, while every consumer — the live detector and the offline timeline alike — feeds the EMA-smoothed distance through those anchors. Smoothing shrinks the spread (measured on a real logged drive: raw σ≈987 vs smoothed σ≈452, raw p97≈5239 vs smoothed p97≈2919), so the top of the scale was much harder to reach than the percentile design implied — replaying a drive through anchors fitted the old way, 4% of frames crossed the 65% "critical" mark scored against raw distances, 0% did scored the way the code actually scores them.calibrate_from_tubnow folds the calibration replay's own clean, time-ordered distances through the same EMA the live detector uses (_replay_ema, matchingFeatureNoveltyDetector.run()'s formula exactly) and fitsnovelty_ood's anchors on that sequence instead, via a newood_anchor_distancesparameter (_build_novelty_block,mc_calibrate.py).novelty_ood_spatial(the offline heat map) is untouched — it has no smoothing or interval concept, so raw per-location distances were already the right thing to fit on.interval = 0, while driving defaults to0.3/0.5. The EMA constant is per update, not per second, so a rate-limited live signal used to be smoothed over a ~6× longer real-time window than the calibration that defined its thresholds (median frame spacing ≈0.05s at 20 Hz vs an effective ≈0.3-0.5s live update spacing — confirmed on a real drive: only 15% of frames actually trigger a fresh EMA fold at the defaultXAI_CONFIDENCE_INTERVAL=0.3).MCDropoutConfidence/FeatureNoveltyDetector/TTAStabilityDetector.run()all now accept an optionalnow=override;calibrate_from_tubreads each frame's own recorded_timestamp_msand passes it through, so confidence and TTA replay through their live Part objects at the tub's actual recorded pace rather than however fast the replay loop happens to run, and novelty's distance sequence is folded through the tub's own timestamps via_replay_emathe same way. Every other caller — the live drive loop,gradcam_uncertainty.py's replay — doesn't passnow=and is unaffected. Tubs recorded before timestamp logging existed fall back to the old behaviour (measure every frame) with a warning, since replaying at the process's own speed has no relationship to how fast the frames were actually driven.CPUEncoderExtractor(donkeycar/parts/ood.py) is currently the only backend.