File size: 19,656 Bytes
2d140d5 1bfa4c3 2d140d5 0b39e51 1bfa4c3 0b39e51 1bfa4c3 0b39e51 1bfa4c3 2d140d5 fdcb8b3 27122b9 fdcb8b3 27122b9 2d140d5 e20e351 476b2b3 1bfa4c3 cc5eb37 0b39e51 2d140d5 27122b9 2d140d5 0052ef8 2d140d5 cc5eb37 476b2b3 d0ddd41 476b2b3 2d140d5 27122b9 0b39e51 cc5eb37 0b39e51 cc5eb37 0052ef8 2d140d5 476b2b3 cc5eb37 476b2b3 0b39e51 cc5eb37 0052ef8 cc5eb37 9db8e78 8c23272 9db8e78 8c23272 9db8e78 d0ddd41 677c187 9db8e78 cc5eb37 9db8e78 cc5eb37 9db8e78 cc5eb37 9db8e78 d0ddd41 cc5eb37 677c187 9db8e78 d0ddd41 5701171 d0ddd41 2d140d5 5bd87c2 2d140d5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 | """Hugging Face Space demo for Problem #10 (Explainable & Auditable Alloy Defect
Detection). Reduced-scope deliverable: upload a micrograph -> defect prediction +
Grad-CAM heatmap, computed live. Conformal Risk Control results are shown as the
pre-computed chart/numbers from the notebook rather than a live-recalibrated model,
per the scope committed in wiki/synthesis/problem10-explainable-auditable-ai.md."""
import json
import os
import gradio as gr
import torch
from model import CLASS_NAMES, GradCAM, eval_transform, load_model, overlay_heatmap
DEVICE = "cpu"
CHECKPOINT_PATH = "model.pt"
model = load_model(CHECKPOINT_PATH, device=DEVICE)
target_layer = model.features[-1]
gradcam = GradCAM(model, target_layer)
def predict(image):
if image is None:
return None, {}
tensor = eval_transform(image.convert("RGB")).unsqueeze(0).to(DEVICE)
with torch.no_grad():
logits = model(tensor)
probs = logits.sigmoid()[0]
top_class = int(probs.argmax())
cam, _ = gradcam(tensor, torch.tensor([top_class]))
overlay = overlay_heatmap(tensor[0], cam[0])
label_scores = {CLASS_NAMES[i]: float(probs[i]) for i in range(len(CLASS_NAMES))}
return overlay, label_scores
SPURIOUS_SETUP = """
### We planted a shortcut. The obvious test missed it.
Grad-CAM tells you *where* the model looked, not whether what it found was real. So
we planted a fake: a second model was trained from scratch on data where every image
of one defect class (`scratches`) carries a crude synthetic "batch ID stamp" in the
corner. Nothing about that stamp is metallurgical — it is exactly the kind of
acquisition artifact (line ID, scale bar, microscope watermark) Problem #10 warns
about.
*The strategy is the course's:* this is **exercise 5 of the course Grad-CAM
notebook** — "create a challenge set and test for shortcut learning" —
proposed there but left unbuilt. Building it out is our contribution.
Below: four images that are **not** `scratches`, with the stamp painted on at test
time. **Top row** is what the model sees; **bottom row** is where it looked. (Each
row's captions sit *above* its own images, so the `Grad-CAM` labels belong to the
row beneath them.) The `p=` values are the model's `scratches` probability for
images containing no scratch defect at all. The stamp pulls attention in every
panel — which is what the third test below quantifies.
"""
def _spurious_markdown():
"""Build the shortcut-learning results from the audit's own saved numbers, so the
text can never drift out of sync with the experiment that produced it."""
try:
with open("assets/spurious-results.json") as fh:
r = json.load(fh)
except (OSError, ValueError):
return "_Results file unavailable._"
ratio = r["gradcam_mass_in_stamp_corner"] / r["stamp_corner_area_fraction"]
return f"""
| Test | Result | |
|---|---|---|
| `scratches` recall, stamp present → removed | {r['recall_poisoned_class_stamped']:.0%} → **{r['recall_poisoned_class_clean']:.0%}** | no collapse |
| Other defects called `scratches` once stamped | {r['baseline_other_classes_called_poisoned']:.0%} → **{r['hijack_rate_other_classes_when_stamped']:.1%}** | hijacked |
| Grad-CAM mass inside the stamp corner | **{r['gradcam_mass_in_stamp_corner']:.1%}** on {r['stamp_corner_area_fraction']:.1%} of pixels | **{ratio:.0f}×** |
**Strip the stamp off and recall stays at {r['recall_poisoned_class_clean']:.0%}.**
The model never became *dependent* on the artifact — it learned the real defect
signal and the stamp in parallel. That is what makes this dangerous: the test almost
anyone would run first (remove the suspicious variable, check whether performance
drops) returns a completely clean bill of health.
What the artifact did do is become an *independently sufficient* cue. Painting it
onto a different defect flips the model to `scratches`
**{r['hijack_rate_other_classes_when_stamped']:.1%}** of the time against a
**{r['baseline_other_classes_called_poisoned']:.0%}** baseline on identical unstamped
images — and it captures **{ratio:.0f}×** more Grad-CAM attention than its
area warrants, firing on the stamp even in cases where the prediction did *not* flip.
So the honest statement to a plant manager isn't "the model is broken." It's: *this
model attends to something with no metallurgical meaning, it will act on it in about
one case in six, and neither accuracy, per-class metrics, the heatmap, nor the
obvious hold-out test can see it.* Latent until a line re-labels, a microscope is
swapped, or sample prep changes — which is why this belongs in periodic
revalidation, not a one-time sign-off.
"""
SPURIOUS_MARKDOWN = _spurious_markdown()
example_dir = "examples"
example_paths = []
if os.path.isdir(example_dir):
example_paths = [
[os.path.join(example_dir, f)]
for f in sorted(os.listdir(example_dir))
if f.lower().endswith((".jpg", ".jpeg", ".png"))
]
# Pin the page to light mode. The embedded result figures are matplotlib defaults with
# white backgrounds, which read as glaring rectangles inside a dark shell; Gradio has no
# "always light" flag, so we set its own __theme param on load and let the page settle.
FORCE_LIGHT_THEME = """
function() {
const url = new URL(window.location);
if (url.searchParams.get('__theme') !== 'light') {
url.searchParams.set('__theme', 'light');
window.location.replace(url.href);
}
}
"""
# The tab bar is how a visitor discovers that tabs 2 and 3 exist at all, and those
# carry the whole contribution. Gradio's default marks the active tab by colour alone
# -- same weight, no fill, no border -- which is a weak cue and useless to anyone with
# red-green colour deficiency; and its container is flex-wrap:nowrap + overflow:hidden,
# so on a 375px phone tab 3 lands offscreen with no way to scroll to it. Below: three
# independent active-state signals (weight, fill, underline) and a scrollable bar.
# Targets .tab-container and aria-selected rather than Gradio's svelte-* hash, which is
# regenerated on every SDK rebuild and would silently stop matching.
TAB_CSS = """
.tab-container { gap: 6px; overflow-x: auto; scrollbar-width: none; }
.tab-container::-webkit-scrollbar { display: none; }
.tab-container button[role="tab"] {
flex: 0 0 auto;
white-space: nowrap;
font-size: 15px;
font-weight: 500;
padding: 11px 20px;
color: #52525b;
background: #f4f4f5;
border: 1px solid #e5e7eb;
border-radius: 8px 8px 0 0;
}
.tab-container button[role="tab"]:hover { background: #ececee; color: #27272a; }
.tab-container button[role="tab"][aria-selected="true"] {
font-weight: 600;
color: #9a3412;
background: #ffffff;
border-bottom: 3px solid #f97316;
}
@media (max-width: 640px) {
.tab-container button[role="tab"] { font-size: 13px; padding: 9px 12px; }
}
"""
with gr.Blocks(title="Explainable & Auditable Alloy Defect Detection",
css=TAB_CSS, js=FORCE_LIGHT_THEME) as demo:
gr.Markdown(
"""
# Explainable & Auditable Alloy Defect Detection
Group 8's submission for Problem #10, MIT Professional Education "Applied
AI for Materials Discovery." A steel-surface defect classifier (NEU-DET, 6
defect classes) plus the interpretability and statistical-guarantee tools a
plant manager or auditor would actually need before acting on it.
**Read the tabs in order** — they are an argument, not a menu.
The model works and explains itself; that explanation can be hijacked by a
meaningless artifact; and while it does pass the standard faithfulness test,
that test turns out to be **sensitive to how you split the data**. What we would
put in front of an auditor is none of those — it is a statistical
guarantee, plus the lesson that audit tools need auditing before you trust them.
"""
)
with gr.Tab("1 - Classify & Explain"):
gr.Markdown(
"### A working model that explains itself\n\n"
"Upload a micrograph (or pick an example below). The model predicts the "
"most likely defect class; the heatmap shows which pixels drove that "
"prediction (**Grad-CAM**, implemented from first principles with PyTorch "
"hooks, following the course's own notebook).\n\n"
"*The classifier itself is deliberately ordinary* — a compact 164k-parameter "
"CNN, trained by the same recipe as the notebook: a stratified 70/10/10/10 split "
"where the test images are never used to fit weights or pick a checkpoint. "
"The notebook's run scores 94.1% on that test split; the separately trained "
"checkpoint served here scores 95.6% on the same split — same recipe and "
"same split, different training run. That is the "
"point: the question is not whether a CNN can score well on NEU-DET (it can), but "
"whether the explanation sitting on top of it means anything. Tabs 2 and 3 test "
"exactly that."
)
with gr.Row():
with gr.Column():
image_input = gr.Image(type="pil", label="Micrograph")
run_button = gr.Button("Classify", variant="primary")
gr.Examples(examples=example_paths, inputs=image_input)
with gr.Column():
overlay_output = gr.Image(label="Grad-CAM overlay (predicted class)")
label_output = gr.Label(label="Predicted class probabilities", num_top_classes=6)
run_button.click(predict, inputs=image_input, outputs=[overlay_output, label_output])
image_input.change(predict, inputs=image_input, outputs=[overlay_output, label_output])
with gr.Tab("2 - Can We Fool the Model?"):
gr.Markdown(SPURIOUS_SETUP)
gr.Image(
value="assets/spurious-correlation.png",
label="Grad-CAM locks onto a planted batch stamp, whether or not the prediction flips",
interactive=False,
)
gr.Markdown(SPURIOUS_MARKDOWN)
with gr.Tab("3 - What We Show an Auditor"):
gr.Markdown(
"""
### First: can we even tell whether the heatmap is faithful?
Tab 2 showed the explanation can be captured by an artifact we planted
ourselves. Fair objection: real data has no planted stamp. So we tested the
heatmap directly on clean images, using **the course's own deletion test**
— delete the pixels Grad-CAM ranks most important and measure how much
the prediction suffers, versus deleting the same number of random pixels.
"""
)
gr.Image(
value="assets/deletion-curve.png",
label="Deletion test: Grad-CAM-ranked pixels versus random pixels (precomputed in the notebook)",
interactive=False,
)
gr.Markdown(
"""
**It passes cleanly.** At 30% of pixels replaced, Grad-CAM-guided deletion has
cost **0.898** of predicted probability; random deletion has cost **0.016**. The
pixels the heatmap points to are the pixels the prediction actually depends on.
> **A note on method.** An earlier version of this project used an 85/15 split
> and got the *opposite* verdict on this same test (0.546 versus 0.718). We
> therefore ran it properly — two split designs, three seeds each, six
> independent runs — and the separation was perfect: 3/3 unfavourable under
> 85/15, 3/3 favourable under the 70/10/10/10 split used here. Notably it is the
> *random* baseline that moves, not the attribution. The full analysis is in the
> notebook. We report the clean-split result because that design never lets test
> images influence weights or checkpoint selection — but the sensitivity is
> why we treat this curve as supporting evidence rather than proof, and why the
> guarantee below is what we would actually put in front of an auditor.
---
### So what survives? Conformal Risk Control
**Not covered anywhere in the course** — taken from Shen & Liu,
[arXiv:2504.17721](https://arxiv.org/abs/2504.17721) (2025). It wraps around
*any* existing detector and needs no retraining.
**Why not just calibrate the confidence scores?** Calibration says *"when I
output 0.9, I'm right about 90% of the time"* — a description of average
past behaviour, true only while conditions hold, and guaranteed by nothing.
Conformal risk control says something categorically different: *"the expected
error rate on future batches is **at most** α"* — a bound that is
**derived, not fitted**. To an auditor those are not the same kind of evidence.
(It carries preconditions, and we examine ours in the limits below rather than
waving them through.)
**How it works, in one paragraph.** Hold back a set of images the model never
trained on. Sweep a confidence bar across them and find the least strict bar
that still keeps the error rate under your chosen budget α. Provided
future images come from the same process as those held-back ones
(*exchangeability*), a future image is just another draw from the same pool
— so the bar that worked on them must work in expectation on it too.
The guarantee is arithmetic, not optimism.
**It was validated, not just asserted:** empirical error stayed at or below
target at **six of the nine** risk levels tested (α = 0.1 through 0.9).
The three that came in over — α = 0.10, 0.20 and 0.90 — miss by
**1.12, 0.55 and 0.83 standard errors** on 180 test images. The bound is on
*expected* error, so realisations scatter either side of the line; near-misses in
both directions are what a correctly behaving bound looks like. An earlier version
of this project reported nine out of nine, which looked stronger and was weaker
— that model had been checkpoint-selected on the very images it was scored
against.
"""
)
gr.Image(
value="assets/conformal-guarantee.png",
label="Conformal Risk Control: empirical FDR against target at nine risk levels; under target at six, over by ~1 standard error at three (precomputed in the notebook)",
interactive=False,
)
gr.Markdown(
"""
#### What the plant actually gets
For each micrograph the method returns a **prediction set** — the defect
classes clearing the calibrated bar. Its *size* is the instruction:
| Prediction set | Meaning | Action |
|---|---|---|
| Exactly one class | Confident and bounded | **Act** — route the batch |
| Two or more | Genuinely ambiguous at this risk level | **Escalate**, candidates attached |
| Empty | Nothing clears the bar | **Abstain** — the model declines |
The plant picks α. That single dial trades throughput against escalation
volume. Pick it at the **strict end**: α = 0.10 abstains on 12.8% of images,
α = 0.90 on 91.7% — within budget and operationally useless. **This, not
the accuracy number and not the heatmap, is what we would put in front of an
auditor.**
#### Four honest limits
- **The bound is an average, not a per-image promise.** It constrains the error
rate across future batches. It does *not* say this particular micrograph has
a ≤α chance of being wrong, and must not be sold that way.
- **Exchangeability is the whole vulnerability.** A new production line, a
replaced microscope, or changed sample prep breaks it *silently* — no
error is raised, the number just quietly stops meaning what you think.
Recalibrate on data from the new source.
- **At lenient α the metric is dominated by abstentions.** We adapted a
method built for pixel-level segmentation, where a prediction set spans
thousands of pixels and is essentially never empty. With six classes, empty
sets are common at strict bars — 91.7% of images at the most lenient
α — and an empty set is scored as a full error. So at that end the
reported "false discovery rate" is very nearly a synonym for "abstention rate."
The useful operating range here is the **low-α end**, where empty sets are
far rarer (12.8%).
- **A precondition of the theorem is not strictly met.** Conformal Risk Control
requires the loss to be *monotone non-increasing* — as you flag more, the
risk must never climb back. False-negative rate satisfies that; **false
discovery rate does not.** Ours is U-shaped: it falls from 0.949 to a minimum of
0.029 around λ ≈ 0.461, then climbs back to 0.755 as the
flagged set fills with false positives — rising at 80 of 399 grid steps.
Every threshold we actually selected (0.004–0.219) sits on the *descending*
branch, where the condition effectively holds; the first rise is not until
λ = 0.486. Capping the search grid at λ ≤ 0.48
would satisfy the precondition outright **without changing a single reported
number**. But that is a property of this data, not a proof. **It is why the chart
above is load-bearing rather than decorative:** with a precondition in question,
checking the bound empirically on held-out data is doing real work.
"""
)
gr.Markdown(
"---\n**The full evidence behind every number on this page is in this Space's "
"[`notebook/`](https://huggingface.co/spaces/joenathan/"
"mit-group8-explainable-defect-detection/tree/main/notebook) folder** — the "
"[Jupyter notebook](https://huggingface.co/spaces/joenathan/"
"mit-group8-explainable-defect-detection/blob/main/notebook/"
"mit-group8-explainable-defect-detection.ipynb) with all outputs, and a "
"[plain-language walkthrough](https://huggingface.co/spaces/joenathan/"
"mit-group8-explainable-defect-detection/blob/main/notebook/"
"mit-group8-notebook-walkthrough.md) of what each cell does and why. The charts "
"above are precomputed there; the notebook is where you can check them.\n\n"
"Group 8 · MIT Professional Education, Applied AI for Materials "
"Discovery, July 2026."
)
if __name__ == "__main__":
demo.launch()
|