Last active
May 27, 2026 09:37
-
-
Save alksily/88d0d7a6f36d70c3851f4e060b52dcbd to your computer and use it in GitHub Desktop.
Neural network in just 300 lines of JavaScript
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
| <!DOCTYPE html> | |
| <html lang="ru"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <title>Live Demo</title> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;700&family=Space+Grotesk:wght@300;500;700&display=swap'); | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| :root { | |
| --bg: #0a0a0f; | |
| --panel: #111118; | |
| --border: #1e1e2e; | |
| --green: #00ff88; | |
| --blue: #4488ff; | |
| --text: #c8c8d8; | |
| --dim: #555568; | |
| --accent: #ff4488; | |
| } | |
| body { | |
| background: var(--bg); | |
| color: var(--text); | |
| font-family: 'JetBrains Mono', monospace; | |
| height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| user-select: none; | |
| } | |
| header { | |
| display: flex; | |
| align-items: center; | |
| gap: 24px; | |
| padding: 14px 24px; | |
| border-bottom: 1px solid var(--border); | |
| background: var(--panel); | |
| flex-shrink: 0; | |
| } | |
| .logo { | |
| font-family: 'Space Grotesk', sans-serif; | |
| font-weight: 700; | |
| font-size: 15px; | |
| letter-spacing: 0.08em; | |
| color: #fff; | |
| } | |
| .logo span { | |
| color: var(--green); | |
| } | |
| .stat { | |
| font-size: 11px; | |
| color: var(--dim); | |
| } | |
| .stat b { | |
| color: var(--text); | |
| font-weight: 400; | |
| } | |
| .hint { | |
| margin-left: auto; | |
| font-size: 11px; | |
| color: var(--dim); | |
| display: flex; | |
| gap: 20px; | |
| } | |
| .hint span { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| .dot-hint { | |
| width: 10px; | |
| height: 10px; | |
| border-radius: 50%; | |
| display: inline-block; | |
| flex-shrink: 0; | |
| } | |
| main { | |
| display: flex; | |
| flex: 1; | |
| overflow: hidden; | |
| } | |
| #canvas { | |
| flex: 1; | |
| cursor: crosshair; | |
| display: block; | |
| } | |
| aside { | |
| width: 220px; | |
| border-left: 1px solid var(--border); | |
| background: var(--panel); | |
| display: flex; | |
| flex-direction: column; | |
| padding: 16px; | |
| gap: 20px; | |
| flex-shrink: 0; | |
| overflow-y: auto; | |
| } | |
| .section-label { | |
| font-size: 10px; | |
| letter-spacing: 0.12em; | |
| text-transform: uppercase; | |
| color: var(--dim); | |
| margin-bottom: 8px; | |
| } | |
| .loss-display { | |
| font-size: 28px; | |
| font-weight: 700; | |
| color: var(--green); | |
| font-variant-numeric: tabular-nums; | |
| line-height: 1; | |
| } | |
| .loss-display.bad { | |
| color: var(--accent); | |
| } | |
| #loss-chart { | |
| width: 100%; | |
| height: 60px; | |
| border: 1px solid var(--border); | |
| border-radius: 4px; | |
| background: var(--bg); | |
| } | |
| .network-viz { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 4px; | |
| height: 100px; | |
| } | |
| .layer-col { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 4px; | |
| flex: 1; | |
| } | |
| .neuron { | |
| width: 14px; | |
| height: 14px; | |
| border-radius: 50%; | |
| border: 1px solid var(--border); | |
| background: var(--bg); | |
| transition: background 0.1s; | |
| flex-shrink: 0; | |
| } | |
| .layer-name { | |
| font-size: 9px; | |
| color: var(--dim); | |
| margin-top: 4px; | |
| text-align: center; | |
| } | |
| button { | |
| background: none; | |
| border: 1px solid var(--border); | |
| color: var(--text); | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 11px; | |
| padding: 8px 12px; | |
| cursor: pointer; | |
| border-radius: 4px; | |
| transition: all 0.15s; | |
| width: 100%; | |
| text-align: left; | |
| } | |
| button:hover { | |
| border-color: var(--green); | |
| color: var(--green); | |
| } | |
| button.danger:hover { | |
| border-color: var(--accent); | |
| color: var(--accent); | |
| } | |
| .iter-count { | |
| font-size: 11px; | |
| color: var(--dim); | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .iter-count b { | |
| color: var(--text); | |
| } | |
| .speed-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| input[type=range] { | |
| flex: 1; | |
| accent-color: var(--green); | |
| cursor: pointer; | |
| } | |
| .speed-label { | |
| font-size: 11px; | |
| color: var(--dim); | |
| width: 28px; | |
| text-align: right; | |
| } | |
| .points-count { | |
| display: flex; | |
| gap: 12px; | |
| font-size: 12px; | |
| } | |
| .pc-green { | |
| color: var(--green); | |
| } | |
| .pc-blue { | |
| color: var(--blue); | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <div class="logo">Live<span>Demo</span></div> | |
| <div class="stat">architecture: <b>2 → 6 → 6 → 2</b></div> | |
| <div class="stat">activation: <b>sigmoid</b></div> | |
| <div class="stat">lr: <b>0.05</b></div> | |
| <div class="hint"> | |
| <span><span class="dot-hint" style="background:var(--green)"></span>ЛКМ — зелёная</span> | |
| <span><span class="dot-hint" style="background:var(--blue)"></span>ПКМ — синяя</span> | |
| </div> | |
| </header> | |
| <main> | |
| <canvas id="canvas"></canvas> | |
| <aside> | |
| <div> | |
| <div class="section-label">Loss</div> | |
| <div class="loss-display" id="loss-val">—</div> | |
| <canvas id="loss-chart"></canvas> | |
| </div> | |
| <div> | |
| <div class="section-label">Итерации</div> | |
| <div class="iter-count">всего: <b id="iter-total">0</b></div> | |
| <div class="iter-count">за кадр: <b id="iter-frame">500</b></div> | |
| <div class="speed-row" style="margin-top:8px"> | |
| <input type="range" id="speed" min="100" max="5000" value="500" step="100"> | |
| <div class="speed-label" id="speed-label">500</div> | |
| </div> | |
| </div> | |
| <div> | |
| <div class="section-label">Точки</div> | |
| <div class="points-count"> | |
| <span class="pc-green" id="cnt-green">● 0</span> | |
| <span class="pc-blue" id="cnt-blue">● 0</span> | |
| </div> | |
| </div> | |
| <div> | |
| <div class="section-label">Сеть</div> | |
| <div class="network-viz" id="net-viz"></div> | |
| </div> | |
| <div style="display:flex;flex-direction:column;gap:8px;margin-top:auto"> | |
| <button id="btn-reset-nn">↺ сбросить сеть</button> | |
| <button id="btn-clear" class="danger">✕ очистить точки</button> | |
| </div> | |
| </aside> | |
| </main> | |
| <script> | |
| const sigmoid = x => 1 / (1 + Math.exp(-x)) | |
| const dsigmoid = y => y * (1 - y) | |
| class NeuralNetwork { | |
| constructor(lr, ...sizes) { | |
| this.lr = lr | |
| this.sizes = sizes | |
| this.init() | |
| } | |
| init() { | |
| this.weights = [] | |
| this.biases = [] | |
| for (let i = 0; i < this.sizes.length - 1; i++) { | |
| const rows = this.sizes[i], cols = this.sizes[i + 1] | |
| this.weights.push(Array.from({length: rows}, () => | |
| Array.from({length: cols}, () => Math.random() * 2 - 1))) | |
| this.biases.push(Array.from({length: cols}, () => Math.random() * 2 - 1)) | |
| } | |
| } | |
| feedForward(inputs) { | |
| this.activations = [inputs.slice()] | |
| let cur = inputs.slice() | |
| for (let l = 0; l < this.weights.length; l++) { | |
| const next = [] | |
| for (let j = 0; j < this.sizes[l + 1]; j++) { | |
| let sum = this.biases[l][j] | |
| for (let k = 0; k < this.sizes[l]; k++) sum += cur[k] * this.weights[l][k][j] | |
| next.push(sigmoid(sum)) | |
| } | |
| cur = next | |
| this.activations.push(cur) | |
| } | |
| return cur | |
| } | |
| backprop(targets) { | |
| const out = this.activations[this.activations.length - 1] | |
| let errors = targets.map((t, i) => t - out[i]) | |
| for (let l = this.weights.length - 1; l >= 0; l--) { | |
| const cur = this.activations[l + 1] | |
| const prev = this.activations[l] | |
| const grads = cur.map((a, i) => errors[i] * dsigmoid(a) * this.lr) | |
| const nextErrors = Array(this.sizes[l]).fill(0) | |
| for (let k = 0; k < this.sizes[l]; k++) { | |
| for (let j = 0; j < this.sizes[l + 1]; j++) { | |
| nextErrors[k] += this.weights[l][k][j] * errors[j] | |
| this.weights[l][k][j] += grads[j] * prev[k] | |
| } | |
| } | |
| for (let j = 0; j < this.sizes[l + 1]; j++) this.biases[l][j] += grads[j] | |
| errors = nextErrors | |
| } | |
| } | |
| } | |
| let nn = new NeuralNetwork(0.05, 2, 6, 6, 2) | |
| let points = [] // {x, y, type} | |
| let iterTotal = 0 | |
| let itersPerFrame = 500 | |
| let lossHistory = [] | |
| const MAX_LOSS_HISTORY = 80 | |
| const canvas = document.getElementById('canvas') | |
| const ctx = canvas.getContext('2d') | |
| const lossChart = document.getElementById('loss-chart') | |
| const lossCtx = lossChart.getContext('2d') | |
| function resize() { | |
| const rect = canvas.parentElement.getBoundingClientRect() | |
| canvas.width = rect.width - 220 | |
| canvas.height = rect.height | |
| lossChart.width = lossChart.offsetWidth | |
| lossChart.height = lossChart.offsetHeight | |
| } | |
| resize() | |
| window.addEventListener('resize', resize) | |
| const RES = 3 // pixel size of each cell | |
| function drawBackground() { | |
| const W = canvas.width, H = canvas.height | |
| const iw = Math.ceil(W / RES), ih = Math.ceil(H / RES) | |
| const imgData = ctx.createImageData(iw, ih) | |
| for (let i = 0; i < iw; i++) { | |
| for (let j = 0; j < ih; j++) { | |
| const nx = i / iw - 0.5 | |
| const ny = j / ih - 0.5 | |
| const out = nn.feedForward([nx, ny]) | |
| const g = out[0], b = out[1] | |
| const t = g / (g + b + 1e-9) // how green (0=blue, 1=green) | |
| const idx = (j * iw + i) * 4 | |
| // green zone: dark teal. blue zone: dark navy | |
| imgData.data[idx] = Math.round(10 + t * 5) | |
| imgData.data[idx + 1] = Math.round(20 + t * 60) | |
| imgData.data[idx + 2] = Math.round(40 + (1 - t) * 80) | |
| imgData.data[idx + 3] = 255 | |
| } | |
| } | |
| // Draw scaled | |
| const tmp = document.createElement('canvas') | |
| tmp.width = iw | |
| tmp.height = ih | |
| tmp.getContext('2d').putImageData(imgData, 0, 0) | |
| ctx.drawImage(tmp, 0, 0, W, H) | |
| } | |
| function drawPoints() { | |
| for (const p of points) { | |
| const col = p.type === 0 ? '#00ff88' : '#4488ff' | |
| ctx.beginPath() | |
| ctx.arc(p.x, p.y, 9, 0, Math.PI * 2) | |
| ctx.fillStyle = col + '22' | |
| ctx.fill() | |
| ctx.beginPath() | |
| ctx.arc(p.x, p.y, 6, 0, Math.PI * 2) | |
| ctx.fillStyle = col | |
| ctx.fill() | |
| ctx.strokeStyle = '#fff4' | |
| ctx.lineWidth = 1 | |
| ctx.stroke() | |
| } | |
| } | |
| function drawBoundary() { | |
| const W = canvas.width, H = canvas.height | |
| const STEP = 6 | |
| ctx.strokeStyle = 'rgba(255,255,255,0.25)' | |
| ctx.lineWidth = 1 | |
| for (let i = 0; i < W; i += STEP) { | |
| for (let j = 0; j < H; j += STEP) { | |
| const nx = i / W - 0.5, ny = j / H - 0.5 | |
| const o = nn.feedForward([nx, ny]) | |
| const diff = o[0] - o[1] | |
| const nx2 = (i + STEP) / W - 0.5, ny2 = (j + STEP) / H - 0.5 | |
| const o2 = nn.feedForward([nx2, ny2]) | |
| const diff2 = o2[0] - o2[1] | |
| if (Math.sign(diff) !== Math.sign(diff2)) { | |
| ctx.beginPath() | |
| ctx.moveTo(i, j) | |
| ctx.lineTo(i + STEP, j + STEP) | |
| ctx.stroke() | |
| } | |
| } | |
| } | |
| } | |
| function drawLossChart() { | |
| const W = lossChart.width, H = lossChart.height | |
| lossCtx.clearRect(0, 0, W, H) | |
| if (lossHistory.length < 2) return | |
| const max = Math.max(...lossHistory, 0.01) | |
| lossCtx.beginPath() | |
| lossCtx.strokeStyle = '#00ff8888' | |
| lossCtx.lineWidth = 1.5 | |
| for (let i = 0; i < lossHistory.length; i++) { | |
| const x = (i / (MAX_LOSS_HISTORY - 1)) * W | |
| const y = H - (lossHistory[i] / max) * (H - 4) - 2 | |
| i === 0 ? lossCtx.moveTo(x, y) : lossCtx.lineTo(x, y) | |
| } | |
| lossCtx.stroke() | |
| // fill | |
| lossCtx.lineTo(W, H) | |
| lossCtx.lineTo(0, H) | |
| lossCtx.fillStyle = '#00ff8811' | |
| lossCtx.fill() | |
| } | |
| function updateNetViz() { | |
| const viz = document.getElementById('net-viz') | |
| const acts = nn.activations | |
| if (!acts) return | |
| viz.innerHTML = '' | |
| const names = ['вход', 'скрытый', 'скрытый', 'выход'] | |
| acts.forEach((layer, li) => { | |
| const col = document.createElement('div') | |
| col.className = 'layer-col' | |
| const show = layer.slice(0, 6) | |
| show.forEach(val => { | |
| const n = document.createElement('div') | |
| n.className = 'neuron' | |
| const v = Math.max(0, Math.min(1, val)) | |
| n.style.background = `rgba(0, 255, 136, ${v * 0.9})` | |
| n.style.borderColor = `rgba(0, 255, 136, ${0.1 + v * 0.5})` | |
| col.appendChild(n) | |
| }) | |
| const lbl = document.createElement('div') | |
| lbl.className = 'layer-name' | |
| lbl.textContent = names[li] || '' | |
| col.appendChild(lbl) | |
| viz.appendChild(col) | |
| }) | |
| } | |
| let lastLossUpdate = 0 | |
| function train() { | |
| if (points.length === 0) return | |
| let lossSum = 0 | |
| const n = itersPerFrame | |
| for (let i = 0; i < n; i++) { | |
| const p = points[Math.floor(Math.random() * points.length)] | |
| const nx = p.x / canvas.width - 0.5 | |
| const ny = p.y / canvas.height - 0.5 | |
| const out = nn.feedForward([nx, ny]) | |
| const targets = p.type === 0 ? [1, 0] : [0, 1] | |
| lossSum += targets.reduce((s, t, i) => s + (t - out[i]) ** 2, 0) | |
| nn.backprop(targets) | |
| } | |
| iterTotal += n | |
| const loss = lossSum / n | |
| lastLossUpdate++ | |
| if (lastLossUpdate >= 3) { | |
| lossHistory.push(loss) | |
| if (lossHistory.length > MAX_LOSS_HISTORY) lossHistory.shift() | |
| lastLossUpdate = 0 | |
| } | |
| // Update stats | |
| const lossEl = document.getElementById('loss-val') | |
| lossEl.textContent = loss.toFixed(4) | |
| lossEl.className = 'loss-display' + (loss > 0.2 ? ' bad' : '') | |
| document.getElementById('iter-total').textContent = iterTotal.toLocaleString() | |
| } | |
| function render() { | |
| train() | |
| drawBackground() | |
| drawBoundary() | |
| drawPoints() | |
| updateNetViz() | |
| drawLossChart() | |
| requestAnimationFrame(render) | |
| } | |
| render() | |
| function getPos(e) { | |
| const r = canvas.getBoundingClientRect() | |
| return {x: e.clientX - r.left, y: e.clientY - r.top} | |
| } | |
| canvas.addEventListener('mousedown', e => { | |
| e.preventDefault() | |
| const {x, y} = getPos(e) | |
| const type = e.button === 2 ? 1 : 0 | |
| points.push({x, y, type}) | |
| updateCounts() | |
| }) | |
| canvas.addEventListener('mousemove', e => { | |
| if (e.buttons === 1 || e.buttons === 2) { | |
| const {x, y} = getPos(e) | |
| const type = e.buttons === 2 ? 1 : 0 | |
| points.push({x, y, type}) | |
| updateCounts() | |
| } | |
| }) | |
| canvas.addEventListener('contextmenu', e => e.preventDefault()) | |
| function updateCounts() { | |
| const g = points.filter(p => p.type === 0).length | |
| const b = points.filter(p => p.type === 1).length | |
| document.getElementById('cnt-green').textContent = `● ${g}` | |
| document.getElementById('cnt-blue').textContent = `● ${b}` | |
| } | |
| document.getElementById('speed').addEventListener('input', function() { | |
| itersPerFrame = +this.value | |
| document.getElementById('speed-label').textContent = this.value | |
| document.getElementById('iter-frame').textContent = this.value | |
| }) | |
| document.getElementById('btn-reset-nn').addEventListener('click', () => { | |
| nn.init() | |
| iterTotal = 0 | |
| lossHistory = [] | |
| document.getElementById('iter-total').textContent = '0' | |
| document.getElementById('loss-val').textContent = '—' | |
| }) | |
| document.getElementById('btn-clear').addEventListener('click', () => { | |
| points = [] | |
| nn.init() | |
| iterTotal = 0 | |
| lossHistory = [] | |
| document.getElementById('iter-total').textContent = '0' | |
| document.getElementById('loss-val').textContent = '—' | |
| updateCounts() | |
| }) | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment