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()