File size: 14,605 Bytes
5041f39
 
 
75bd1a0
 
 
 
 
 
 
5041f39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75bd1a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5041f39
 
75bd1a0
5041f39
 
 
 
 
 
 
 
75bd1a0
 
 
 
5041f39
 
 
 
75bd1a0
5041f39
 
 
 
75bd1a0
 
 
 
 
 
 
7f8c557
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ad16400
 
42998b1
75bd1a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56a6111
 
75bd1a0
 
 
 
 
 
 
 
 
5041f39
42998b1
 
 
 
 
 
 
 
 
 
 
 
 
5041f39
75bd1a0
 
 
 
 
 
5041f39
 
 
 
75bd1a0
5041f39
 
75bd1a0
 
5041f39
 
 
 
 
 
 
75bd1a0
 
5041f39
 
 
 
75bd1a0
 
5041f39
 
 
 
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
"""
Gradio demo for the from-scratch neural network.

Draw a digit (or load a real MNIST test image) and a multilayer perceptron,
written by hand in NumPy with no deep-learning framework, classifies it. The UI
is bespoke, not the stock label bars: the winning digit fills a hero card with a
confidence ring, and every class from 0 to 9 renders as an animated bar chart.
All of it is drawn by the Python function as HTML into a single gr.HTML panel.
Inference runs the real package (nn/), the same code the test suite
gradient-checks.

Run locally:   pip install -r requirements.txt && python app.py
On Hugging Face Spaces this file is the entry point (app_file: app.py).
"""
from __future__ import annotations

from pathlib import Path

import numpy as np
import gradio as gr

from nn.model import MLP

ACCENT = "#0891b2"  # cyan

MODEL = MLP()
_weights = Path("weights/mnist_mlp.npz")
if _weights.exists():
    MODEL.load_state(dict(np.load(_weights)))


def _predict_28(arr28: np.ndarray) -> dict:
    """arr28: (28,28) float in [0,1], white digit on black. Returns {label: prob}."""
    x = arr28.reshape(1, 784).astype(np.float32)
    probs = MODEL.probabilities(x)[0]
    return {str(i): float(probs[i]) for i in range(10)}


def _mnist_normalize(ink: np.ndarray) -> np.ndarray:
    """MNIST-style preprocessing: crop to the digit, scale to 20px, center by mass in 28x28."""
    from PIL import Image  # lazy: only needed at draw-time (installed on the Space)

    ink = ink.astype(np.float32)
    if ink.max() > 0:
        ink = ink / ink.max() * 255.0
    ys, xs = np.where(ink > 30)
    if len(xs) == 0:
        return np.zeros((28, 28), np.float32)
    y0, y1, x0, x1 = ys.min(), ys.max(), xs.min(), xs.max()
    crop = ink[y0:y1 + 1, x0:x1 + 1]

    h, w = crop.shape
    scale = 20.0 / max(h, w)
    new = (max(1, int(round(w * scale))), max(1, int(round(h * scale))))
    small = np.asarray(Image.fromarray(crop.astype(np.uint8)).resize(new, Image.BILINEAR), np.float32)

    canvas = np.zeros((28, 28), np.float32)
    sh, sw = small.shape
    top, left = (28 - sh) // 2, (28 - sw) // 2
    canvas[top:top + sh, left:left + sw] = small
    return canvas / 255.0


# --------------------------------------------------------------------------- #
# Custom HTML rendering (replaces stock gr.Label bars)
# --------------------------------------------------------------------------- #

EMPTY_STATE = """
<div class="nn-empty">
  <div class="nn-empty-emoji">✏️</div>
  <div class="nn-empty-text">Draw a digit or pick a test image, and the network names it.</div>
  <div class="nn-empty-sub">Ten classes, the digits 0 through 9.</div>
</div>
"""


def _render(dist: dict) -> str:
    """Render the prediction as a hero digit card plus an animated 0 to 9 bar chart (HTML)."""
    if not dist:
        return EMPTY_STATE

    ordered = sorted(dist.items(), key=lambda kv: kv[1], reverse=True)
    top_label, top_p = ordered[0]
    conf = round(top_p * 100)

    hero = f"""
    <div class="nn-hero" style="--c:{ACCENT}">
      <div class="nn-hero-digit">{top_label}</div>
      <div class="nn-hero-body">
        <div class="nn-hero-label">It reads a {top_label}</div>
        <div class="nn-hero-sub">{conf}% confident</div>
      </div>
      <div class="nn-hero-ring" style="--p:{top_p:.4f}">
        <span>{conf}<small>%</small></span>
      </div>
    </div>
    """

    # Bar chart in natural digit order 0 to 9, so it reads like a keypad.
    rows = []
    for i in range(10):
        label = str(i)
        p = dist.get(label, 0.0)
        pct = round(p * 100)
        win = " nn-row-win" if label == top_label else ""
        bar_color = ACCENT if label == top_label else "#cbd5e1"
        rows.append(f"""
        <div class="nn-row{win}">
          <div class="nn-row-name">{label}</div>
          <div class="nn-track">
            <div class="nn-fill" style="width:{p*100:.2f}%;background:{bar_color}"></div>
          </div>
          <div class="nn-pct">{pct}%</div>
        </div>
        """)
    chart = f'<div class="nn-chart">{"".join(rows)}</div>'
    return f'<div class="nn-result">{hero}{chart}</div>'


def classify_drawing(value) -> str:
    """Handle gr.Sketchpad output (dict with 'composite', or a raw array)."""
    if value is None:
        return EMPTY_STATE
    img = value["composite"] if isinstance(value, dict) else value
    img = np.asarray(img)
    if img.ndim == 3 and img.shape[2] == 4:      # RGBA -> alpha marks the strokes
        ink = img[..., 3]
    elif img.ndim == 3:                          # RGB -> dark strokes on light bg
        ink = 255 - img[..., :3].mean(axis=2)
    else:                                        # already grayscale
        ink = img if img.mean() < 128 else 255 - img
    arr = _mnist_normalize(ink)
    if arr.max() <= 0:
        return EMPTY_STATE
    return _render(_predict_28(arr))


def load_example(digit: str):
    arr = np.load(f"examples/digit_{digit}.npy").astype(np.float32)
    return (arr * 255).astype(np.uint8), _render(_predict_28(arr))


EXAMPLE_DIGITS = [str(i) for i in range(10) if Path(f"examples/digit_{i}.npy").exists()]


CSS = """
:root {
  --nn-bg1:#ecfeff; --nn-bg2:#eff6ff; --nn-ink:#0f172a; --nn-muted:#64748b;
  --nn-card:#ffffff; --nn-line:rgba(15,23,42,.08); --nn-accent:%s;
  --nn-font:'Plus Jakarta Sans','Inter',system-ui,sans-serif;
}
/* Light lock: HF Spaces default to dark mode, but this UI is designed light.
   Override Gradio's dark theme variables so it renders light everywhere. */
:root, .dark, gradio-app.dark {
  color-scheme: light !important;
  --body-background-fill:#ffffff !important;
  --background-fill-primary:#ffffff !important;
  --background-fill-secondary:#f6f6fb !important;
  --block-background-fill:#ffffff !important;
  --block-label-background-fill:#ffffff !important;
  --input-background-fill:#ffffff !important;
  --neutral-950:#16131f !important;
  --border-color-primary:rgba(20,16,40,.12) !important;
  --body-text-color:var(--nn-ink) !important;
  --body-text-color-subdued:var(--nn-muted) !important;
  --block-title-text-color:var(--nn-ink) !important;
  --block-info-text-color:var(--nn-muted) !important;
}
html, body, gradio-app, .dark, .gradio-container { background:#ffffff !important; }
/* Sketchpad: whiten the editor stage behind the transparent drawing canvas */
.image-container, .image-container *, .image-container canvas, .empty.wrap { background:#ffffff !important; }
.gradio-container, .gradio-container * { color: var(--nn-ink); }
.gradio-container { max-width: 880px !important; background:
  radial-gradient(1200px 500px at 15%% -10%%, var(--nn-bg1), transparent 60%%),
  radial-gradient(1000px 500px at 110%% 10%%, var(--nn-bg2), transparent 55%%) !important; }
.gradio-container, .gradio-container * { font-family: var(--nn-font); }

/* Header */
#nn-head { text-align:center; padding: 18px 8px 4px; }
#nn-head .nn-pill { display:inline-block; background:#0f172a; color:#fff; border-radius:999px;
  padding:5px 13px; font-size:.7rem; font-weight:700; letter-spacing:.08em; margin-bottom:14px; }
#nn-head h1 { margin:0; font-size:2.05rem; font-weight:800; letter-spacing:-.02em; color:var(--nn-ink);
  background:linear-gradient(90deg,#0891b2,#0ea5e9,#0f172a); -webkit-background-clip:text;
  background-clip:text; -webkit-text-fill-color:transparent; }
#nn-head p { margin:10px auto 0; max-width:600px; color:var(--nn-muted); font-size:1.02rem; line-height:1.55; }

/* Inputs */
#nn-go { border-radius:14px !important; font-weight:800 !important; font-size:1rem !important;
  background:linear-gradient(135deg,#0891b2,#0ea5e9) !important; border:none !important; color:#fff !important;
  box-shadow:0 10px 26px rgba(8,145,178,.32) !important; transition:transform .12s ease, box-shadow .12s ease !important; }
#nn-go:hover { transform:translateY(-1px); box-shadow:0 14px 32px rgba(8,145,178,.42) !important; }

/* Results panel */
.nn-result { animation: nn-fade .35s ease both; }
@keyframes nn-fade { from{opacity:0; transform:translateY(8px)} to{opacity:1; transform:none} }

.nn-hero { display:flex; align-items:center; gap:20px; padding:22px 26px; border-radius:20px;
  background:var(--nn-card); border:1px solid var(--nn-line); position:relative; overflow:hidden;
  box-shadow:0 18px 44px color-mix(in srgb, var(--c) 22%%, transparent); }
.nn-hero::before { content:""; position:absolute; inset:0; opacity:.10;
  background:radial-gradient(420px 160px at 8%% 0%%, var(--c), transparent 70%%); }
.nn-hero-digit { font-size:70px; line-height:1; font-weight:800; color:var(--c);
  font-variant-numeric:tabular-nums; min-width:64px; text-align:center;
  filter:drop-shadow(0 6px 12px color-mix(in srgb,var(--c) 40%%,transparent)); }
.nn-hero-body { flex:1; }
.nn-hero-label { font-size:1.65rem; font-weight:800; color:var(--nn-ink); letter-spacing:-.01em; }
.nn-hero-sub { color:var(--nn-muted); font-size:.98rem; margin-top:2px; }
.nn-hero-ring { width:64px; height:64px; border-radius:50%%; display:grid; place-items:center; flex-shrink:0;
  background:conic-gradient(var(--c) calc(var(--p)*360deg), color-mix(in srgb,var(--c) 16%%, #fff) 0); }
.nn-hero-ring span { width:50px; height:50px; border-radius:50%%; background:var(--nn-card); display:grid; place-items:center;
  font-weight:800; color:var(--nn-ink); font-size:1.02rem; }
.nn-hero-ring small { font-size:.62rem; font-weight:700; color:var(--nn-muted); }

.nn-chart { margin-top:14px; padding:16px 22px; border-radius:18px; background:var(--nn-card);
  border:1px solid var(--nn-line); box-shadow:0 10px 30px rgba(15,23,42,.05); }
.nn-row { display:flex; align-items:center; gap:14px; padding:5px 0; }
.nn-row-name { width:22px; text-align:center; font-weight:800; color:var(--nn-muted); font-size:1rem;
  font-variant-numeric:tabular-nums; }
.nn-row-win .nn-row-name { color:var(--nn-accent); }
.nn-track { flex:1; height:13px; border-radius:999px; background:#eef2f7; overflow:hidden; }
.nn-fill { height:100%%; border-radius:999px; transform-origin:left; animation: nn-grow .65s cubic-bezier(.2,.8,.2,1) both; }
@keyframes nn-grow { from{transform:scaleX(0)} to{transform:scaleX(1)} }
.nn-pct { width:42px; text-align:right; font-variant-numeric:tabular-nums; font-weight:700; color:var(--nn-muted); font-size:.9rem; }
.nn-row-win .nn-pct { color:var(--nn-ink); }

/* Empty state */
.nn-empty { text-align:center; padding:42px 20px; border-radius:20px; background:var(--nn-card);
  border:1px dashed var(--nn-line); }
.nn-empty-emoji { font-size:2.6rem; }
.nn-empty-text { margin-top:10px; font-weight:700; color:var(--nn-ink); font-size:1.05rem; }
.nn-empty-sub { margin-top:4px; color:var(--nn-muted); font-size:.92rem; }

/* Footer */
.nn-footer { margin-top:22px; padding-top:16px; border-top:1px solid var(--nn-line);
  text-align:center; font-size:.88rem; color:var(--nn-muted); line-height:1.9; }
.nn-footer a { text-decoration:none; font-weight:700; color:var(--nn-accent); }
.nn-meta { text-align:center; color:var(--nn-muted); font-size:.82rem; margin-top:10px; }
""" % ACCENT


FOOTER = """
<div class="nn-footer">
🧠 Neural network built from scratch in NumPy, no framework, by <b>Laela Zorana</b><br>
<a href="https://laelazorana.github.io">Portfolio</a> &middot; <a href="https://www.linkedin.com/in/laela-zorana-362309114">LinkedIn</a> &middot; <a href="https://github.com/LaelaZorana">GitHub</a> &middot; <a href="https://huggingface.co/LaelaZ">Hugging Face</a><br>
<span style="opacity:.7">More demos:</span> <a href="https://huggingface.co/spaces/LaelaZ/distilbert-emotion">Emotion</a> &middot; <a href="https://huggingface.co/spaces/LaelaZ/cnn-gradcam">CNN + Grad-CAM</a> &middot; <a href="https://huggingface.co/spaces/LaelaZ/timeseries-lstm">Time-Series</a> &middot; <a href="https://huggingface.co/spaces/LaelaZ/ai-agent-scenario-qc">Scenario QC</a> &middot; <a href="https://huggingface.co/spaces/LaelaZ/rlhf-pairwise-rater">RLHF Rater</a> &middot; <a href="https://huggingface.co/spaces/LaelaZ/scorm-qa-validator">SCORM QA</a>
</div>
"""


theme = gr.themes.Soft(
    primary_hue="cyan", neutral_hue="slate",
    font=[gr.themes.GoogleFont("Plus Jakarta Sans"), gr.themes.GoogleFont("Inter"),
          "system-ui", "sans-serif"],
)

# Hugging Face Spaces default to dark mode. This bespoke UI is designed light, so
# force the light theme on load (one redirect if the param is missing, then never again).
FORCE_LIGHT = """
function() {
  const u = new URL(window.location.href);
  if (u.searchParams.get('__theme') !== 'light') {
    u.searchParams.set('__theme', 'light');
    window.location.replace(u.href);
  }
}
"""

with gr.Blocks(title="Neural Net From Scratch (NumPy)", theme=theme, css=CSS, js=FORCE_LIGHT) as demo:
    gr.HTML(
        '<div id="nn-head"><span class="nn-pill">DEEP LEARNING · NO FRAMEWORK</span>'
        "<h1>A neural network I wrote by hand</h1>"
        "<p>This is a multilayer perceptron written from scratch in NumPy, every forward "
        "and backward pass, the softmax, the Adam optimizer. No PyTorch, no TensorFlow. "
        "It hits about 97.7% on MNIST, and its backprop is checked against finite-difference "
        "gradients in the test suite. Draw a digit, or load a real test image.</p></div>"
    )

    with gr.Tab("✏️ Draw a digit"):
        with gr.Row():
            sketch = gr.Sketchpad(label="Draw 0 to 9", type="numpy", image_mode="RGBA",
                                  canvas_size=(280, 280),
                                  brush=gr.Brush(default_size=16, colors=["#000000"], color_mode="fixed"))
            draw_out = gr.HTML(EMPTY_STATE)
        gr.Markdown("*Draw thick and centered for best results. The model was trained on centered MNIST digits.*")
        sketch.change(classify_drawing, inputs=sketch, outputs=draw_out)

    with gr.Tab("🔢 Try a test image"):
        with gr.Row():
            with gr.Column():
                digit_dd = gr.Dropdown(EXAMPLE_DIGITS, value=(EXAMPLE_DIGITS[0] if EXAMPLE_DIGITS else None),
                                       label="Real MNIST test digit")
                ex_img = gr.Image(label="Input (28x28)", height=200, image_mode="L")
            ex_out = gr.HTML(EMPTY_STATE)
        digit_dd.change(load_example, inputs=digit_dd, outputs=[ex_img, ex_out])
        demo.load(load_example, inputs=digit_dd, outputs=[ex_img, ex_out])

    gr.HTML(FOOTER)
    gr.HTML('<div class="nn-meta">Runs the actual package (nn/), the same code the test suite '
            'gradient-checks. Architecture: 784 to 256 to 128 to 10, ReLU, softmax cross-entropy, Adam.</div>')


if __name__ == "__main__":
    demo.launch()