// mulberry32 — 문제 번호(시드)가 같으면 항상 같은 문제가 생성된다
dojoRng = function (seed) {
let a = (seed * 7919 + 104729) >>> 0;
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}도장 3주차: 손실함수
도장 사용법
- 대응 이론: Ch03 딥러닝의 학습 (전반: 순전파 → 손실 → 교차 엔트로피 → Softmax)
- 학번을 입력하면 나만의 문제가 나옵니다. 새 문제 버튼으로 무한 반복 연습.
- 이 주차는 계산기가 필요합니다. 지수 e^x 와 자연로그 ln 을 쓰는 문제가 있습니다.
- 유형은 5개 — [기본] 3 → [응용] 1 → [도전] 1.
- 훈련 기준: 각 유형에서 연속 3문제 무실수.
이 수업의 계산 약속
- log 는 항상 자연로그 ln 입니다 (밑 = e ≈ 2.71828). 상용로그(밑 10)가 아닙니다.
- 별도 지시가 없으면 답은 소수 넷째 자리에서 반올림하여 셋째 자리까지 씁니다.
// 정답 비교 — tol 이내면 정답 처리 (학생의 반올림 오차 허용)
dojoOk = (user, answer, tol = 0.005) =>
user !== null && user !== undefined && Number.isFinite(user) && Math.abs(user - answer) <= toldojoMark = (label, ok, answerShown) => `
<div style="display:flex;align-items:center;gap:0.5em;padding:0.25em 0;">
<span style="font-size:1.1em;">${ok ? "✅" : "❌"}</span>
<span style="font-weight:600;">${label}</span>
<span style="color:${ok ? "#2E7D32" : "#8C1515"};">
${ok ? "정답" : (answerShown != null ? "오답 — 다시 계산해 보세요" : "오답")}
</span>
</div>`// 문제 카드
dojoProblem = (title, bodyHtml) => html`
<div style="font-family:'Pretendard',sans-serif;border:1.5px solid #8C1515;border-radius:8px;
overflow:hidden;margin:0.6em 0;max-width:720px;">
<div style="background:#8C1515;color:white;padding:0.4em 0.9em;font-weight:700;font-size:0.95em;">
${title}
</div>
<div style="padding:0.7em 0.9em;line-height:1.9;font-size:0.95em;">${bodyHtml}</div>
</div>`// 채점 결과 카드
dojoResult = (marks, allOk) => html`
<div style="font-family:'Pretendard',sans-serif;border-radius:8px;max-width:720px;margin:0.4em 0;
padding:0.6em 0.9em;font-size:0.92em;
background:${allOk ? "#E8F5E9" : "#FFF8E1"};
border:1.5px solid ${allOk ? "#2E7D32" : "#D4A017"};">
${marks.join("")}
<div style="margin-top:0.35em;font-weight:700;color:${allOk ? "#2E7D32" : "#B8860B"};">
${allOk ? "🎉 전부 정답! 문제 번호를 바꿔 한 번 더 연습해 보세요." : "빨간 항목을 다시 풀어 보세요. 막히면 풀이 보기를 켜세요."}
</div>
</div>`// 풀이 카드
dojoSolution = (stepsHtml) => html`
<div style="font-family:'Pretendard',sans-serif;border-radius:8px;max-width:720px;margin:0.4em 0;
padding:0.7em 0.9em;background:#E3F2FD;border:1.5px solid #1565C0;
line-height:2.0;font-size:0.92em;">
<div style="font-weight:700;color:#1565C0;margin-bottom:0.2em;">풀이</div>
${stepsHtml}
</div>`// FNV-1a — 학번 문자열을 정수로. 사람마다 다른 문제가 나오게 하는 출발점.
dojoHash = function (str) {
let h = 2166136261 >>> 0;
const s = String(str);
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619) >>> 0;
}
return h >>> 0;
}// (학번, 주차, 유형, 라운드) → 문제 번호.
// 학번이 다르면 다른 문제, 같은 학번·같은 라운드면 항상 같은 문제(재현 가능).
dojoSeed = function (sid, week, type, round) {
const key = (sid && String(sid).trim()) ? String(sid).trim() : "guest";
return (dojoHash(key + "|" + week + "|" + type + "|" + round) % 99999) + 1;
}// 학번 입력 — 브라우저에 저장되어 모든 도장 페이지에서 유지된다.
viewof dojoSid = Inputs.text({
label: "학번",
placeholder: "예: 202312345",
value: (() => { try { return localStorage.getItem("dojoSid") || ""; } catch (e) { return ""; } })(),
width: 260
}){
try { localStorage.setItem("dojoSid", dojoSid || ""); } catch (e) {}
const named = dojoSid && dojoSid.trim();
return html`<div style="font-family:'Pretendard',sans-serif;font-size:0.85em;
padding:0.45em 0.8em;border-radius:6px;max-width:720px;margin:0.1em 0 0.9em;
background:${named ? "#E8F5E9" : "#FFF8E1"};
border:1px solid ${named ? "#A5D6A7" : "#E8D9A0"};color:${named ? "#2E7D32" : "#8A6D1B"};">
${named
? `학번 <strong>${dojoSid.trim()}</strong> 기준으로 문제가 생성됩니다 — 옆 사람과 다른 문제가 나옵니다.`
: `학번을 입력하면 <strong>나만의 문제 세트</strong>가 만들어집니다. (비워 두면 공용 연습 문제)`}
</div>`;
}// "새 문제" 버튼 + 문제 코드 표시를 한 줄로 묶는 헤더
dojoBar = (code) => html`
<div style="font-family:'Pretendard',sans-serif;font-size:0.82em;color:#666;
margin:-0.3em 0 0.5em;">
문제 코드 <code style="background:#F1F3F5;padding:0.1em 0.4em;border-radius:4px;
color:#8C1515;font-weight:700;">${code}</code>
<span style="color:#999;"> — 질문할 때 이 코드를 알려주세요</span>
</div>`유형 A — MSE 계산 [기본]
rA = {
const rng = dojoRng(rA_seed);
const n = dojoInt(rng, 4, 5);
const ys = [], hs = [];
for (let i = 0; i < n; i++) {
const y = dojoInt(rng, 150, 190);
const e = dojoInt(rng, -6, 6);
ys.push(y); hs.push(y - e);
}
const errs = ys.map((y, i) => y - hs[i]);
const sq = errs.map(e => e * e);
const sum = sq.reduce((a, b) => a + b, 0);
return { n, ys, hs, errs, sq, sum, mse: Math.round(sum / n * 1000) / 1000 };
}dojoProblem(`문제 A — 평균제곱오차`, `
키(cm)를 예측하는 모형의 결과가 다음과 같다.
<div style="margin:0.5em 0;">
<table style="border-collapse:collapse;font-size:0.95em;">
<tr style="background:#8C1515;color:#fff;">
<th style="padding:0.25em 0.8em;">i</th>
${rA.ys.map((_, i) => `<th style="padding:0.25em 0.8em;">${i + 1}</th>`).join("")}
</tr>
<tr><td style="padding:0.25em 0.8em;border:1px solid #ddd;"><strong>실제 y</strong></td>
${rA.ys.map(v => `<td style="padding:0.25em 0.8em;border:1px solid #ddd;text-align:center;">${v}</td>`).join("")}</tr>
<tr><td style="padding:0.25em 0.8em;border:1px solid #ddd;"><strong>예측 ŷ</strong></td>
${rA.hs.map(v => `<td style="padding:0.25em 0.8em;border:1px solid #ddd;text-align:center;">${v}</td>`).join("")}</tr>
</table>
</div>
<strong>MSE = (1/n)·Σ(yᵢ − ŷᵢ)²</strong> 를 구하라. (소수 셋째 자리까지)`)viewof rA_ans = Inputs.number({ label: "MSE =", step: 0.001 })
viewof rA_grade = Inputs.toggle({ label: "채점", value: false })
viewof rA_show = Inputs.toggle({ label: "풀이 보기", value: false })rA_grade ? dojoResult(
[dojoMark("MSE", dojoOk(rA_ans, rA.mse, 0.0015))],
dojoOk(rA_ans, rA.mse, 0.0015)
) : html``rA_show ? dojoSolution(`
① 각 오차와 그 제곱:<br>
${rA.ys.map((y, i) => ` (${y} − ${rA.hs[i]}) = ${rA.errs[i]} → ${rA.errs[i]}² = ${rA.sq[i]}`).join("<br>")}<br>
② 제곱합 = ${rA.sq.join(" + ")} = <strong>${rA.sum}</strong><br>
③ MSE = ${rA.sum} / ${rA.n} = <strong>${rA.mse}</strong><br>
<span style="color:#555;">제곱하므로 오차의 부호는 사라지고, 큰 오차에 더 큰 벌칙이 간다.</span>`) : html``유형 B — Softmax 확률 [기본]
rB = {
const rng = dojoRng(rB_seed);
const z = [dojoInt(rng, -20, 30) / 10, dojoInt(rng, -20, 30) / 10, dojoInt(rng, -20, 30) / 10];
const ex = z.map(v => Math.exp(v));
const s = ex.reduce((a, b) => a + b, 0);
const p = ex.map(v => Math.round(v / s * 1000) / 1000);
return { z, ex, s, p, argmax: p.indexOf(Math.max(...p)) + 1 };
}dojoProblem(`문제 B — Softmax`, `
3개 클래스 분류에서 출력층의 값이 다음과 같다.
<div style="text-align:center;margin:0.5em 0;font-size:1.05em;">
<strong>z₁ = ${rB.z[0]}, z₂ = ${rB.z[1]}, z₃ = ${rB.z[2]}</strong>
</div>
Softmax(zₖ) = e^zₖ / (e^z₁ + e^z₂ + e^z₃) 으로
각 클래스의 확률을 <strong>소수 셋째 자리까지</strong> 구하라.`)viewof rB_p1 = Inputs.number({ label: "p₁ =", step: 0.001 })
viewof rB_p2 = Inputs.number({ label: "p₂ =", step: 0.001 })
viewof rB_p3 = Inputs.number({ label: "p₃ =", step: 0.001 })
viewof rB_grade = Inputs.toggle({ label: "채점", value: false })
viewof rB_show = Inputs.toggle({ label: "풀이 보기", value: false })rB_grade ? dojoResult(
[dojoMark("p₁", dojoOk(rB_p1, rB.p[0], 0.0015)),
dojoMark("p₂", dojoOk(rB_p2, rB.p[1], 0.0015)),
dojoMark("p₃", dojoOk(rB_p3, rB.p[2], 0.0015))],
dojoOk(rB_p1, rB.p[0], 0.0015) && dojoOk(rB_p2, rB.p[1], 0.0015) && dojoOk(rB_p3, rB.p[2], 0.0015)
) : html``rB_show ? dojoSolution(`
① 각 z에 지수를 취한다:<br>
e^${rB.z[0]} = ${rB.ex[0].toFixed(4)},
e^${rB.z[1]} = ${rB.ex[1].toFixed(4)},
e^${rB.z[2]} = ${rB.ex[2].toFixed(4)}<br>
② 분모(합) = ${rB.ex.map(v => v.toFixed(4)).join(" + ")} = <strong>${rB.s.toFixed(4)}</strong><br>
③ 각각을 합으로 나눈다:<br>
p₁ = ${rB.ex[0].toFixed(4)}/${rB.s.toFixed(4)} = <strong>${rB.p[0].toFixed(3)}</strong><br>
p₂ = ${rB.ex[1].toFixed(4)}/${rB.s.toFixed(4)} = <strong>${rB.p[1].toFixed(3)}</strong><br>
p₃ = ${rB.ex[2].toFixed(4)}/${rB.s.toFixed(4)} = <strong>${rB.p[2].toFixed(3)}</strong><br>
<span style="color:#555;">검산: 세 확률의 합은 항상 1이다 (반올림 오차 제외).
가장 큰 z(=z${["₁","₂","₃"][rB.argmax - 1]})가 가장 높은 확률을 받는다.</span>`) : html``유형 C — 이진 교차 엔트로피 (1건) [기본]
rC = {
const rng = dojoRng(rC_seed);
const y = dojoInt(rng, 0, 1);
const p = dojoInt(rng, 5, 95) / 100;
const used = y === 1 ? p : 1 - p;
return { y, p, used, loss: Math.round(-Math.log(used) * 1000) / 1000 };
}dojoProblem(`문제 C — 이진 교차 엔트로피`, `
이진 분류에서 어떤 데이터 한 건의 실제 정답이 <strong>y = ${rC.y}</strong> 이고,
모형이 출력한 확률(클래스 1일 확률)이 <strong>ŷ = ${rC.p}</strong> 이다.<br>
이 한 건의 손실을 구하라.
<div style="text-align:center;margin:0.5em 0;">
<strong>L = −[ y·ln(ŷ) + (1−y)·ln(1−ŷ) ]</strong>
</div>
(ln 은 자연로그, 소수 셋째 자리까지)`)viewof rC_ans = Inputs.number({ label: "L =", step: 0.001 })
viewof rC_grade = Inputs.toggle({ label: "채점", value: false })
viewof rC_show = Inputs.toggle({ label: "풀이 보기", value: false })rC_grade ? dojoResult(
[dojoMark("손실 L", dojoOk(rC_ans, rC.loss, 0.0015))],
dojoOk(rC_ans, rC.loss, 0.0015)
) : html``rC_show ? dojoSolution(`
① y = ${rC.y} 이므로 ${rC.y === 1
? "둘째 항 (1−y)·ln(1−ŷ) 이 0이 되어 사라진다. 남는 것은 −ln(ŷ)."
: "첫째 항 y·ln(ŷ) 이 0이 되어 사라진다. 남는 것은 −ln(1−ŷ)."}<br>
② L = −ln(${rC.used}) = −(${Math.log(rC.used).toFixed(4)}) = <strong>${rC.loss.toFixed(3)}</strong><br>
<span style="color:#555;">정답 클래스에 부여한 확률이 ${rC.used} 였다.
이 값이 1에 가까울수록 손실은 0에, 0에 가까울수록 손실은 무한대로 커진다 —
<strong>확신에 찬 오답</strong>에 큰 벌칙을 주는 것이 교차 엔트로피의 성질이다.</span>`) : html``유형 D — Softmax + 다중 클래스 교차 엔트로피 [응용]
rD = {
const rng = dojoRng(rD_seed);
const z = [dojoInt(rng, -15, 25) / 10, dojoInt(rng, -15, 25) / 10, dojoInt(rng, -15, 25) / 10];
const ex = z.map(v => Math.exp(v));
const s = ex.reduce((a, b) => a + b, 0);
const p = ex.map(v => v / s);
const k = dojoInt(rng, 0, 2);
return {
z, ex, s, k,
pk: Math.round(p[k] * 1000) / 1000,
loss: Math.round(-Math.log(p[k]) * 1000) / 1000
};
}dojoProblem(`문제 D — 다중 클래스 손실`, `
3개 클래스 분류에서 출력층 값이
<strong>z₁ = ${rD.z[0]}, z₂ = ${rD.z[1]}, z₃ = ${rD.z[2]}</strong> 이고,
이 데이터의 <strong>정답은 클래스 ${rD.k + 1}</strong> 이다.<br>
① 정답 클래스의 Softmax 확률, ② 교차 엔트로피 손실
<strong>L = −ln(ŷ<sub>정답</sub>)</strong> 을 구하라. (소수 셋째 자리까지)`)viewof rD_ans_p = Inputs.number({ label: "정답 클래스 확률 =", step: 0.001 })
viewof rD_ans_l = Inputs.number({ label: "L =", step: 0.001 })
viewof rD_grade = Inputs.toggle({ label: "채점", value: false })
viewof rD_show = Inputs.toggle({ label: "풀이 보기", value: false })rD_grade ? dojoResult(
[dojoMark("정답 클래스 확률", dojoOk(rD_ans_p, rD.pk, 0.0015)),
dojoMark("손실 L", dojoOk(rD_ans_l, rD.loss, 0.0025))],
dojoOk(rD_ans_p, rD.pk, 0.0015) && dojoOk(rD_ans_l, rD.loss, 0.0025)
) : html``rD_show ? dojoSolution(`
① 지수: e^${rD.z[0]} = ${rD.ex[0].toFixed(4)}, e^${rD.z[1]} = ${rD.ex[1].toFixed(4)},
e^${rD.z[2]} = ${rD.ex[2].toFixed(4)} → 합 = <strong>${rD.s.toFixed(4)}</strong><br>
정답은 클래스 ${rD.k + 1} 이므로
ŷ = ${rD.ex[rD.k].toFixed(4)} / ${rD.s.toFixed(4)} = <strong>${rD.pk.toFixed(3)}</strong><br>
② L = −ln(${rD.pk.toFixed(3)}) = <strong>${rD.loss.toFixed(3)}</strong><br>
<span style="color:#555;">원-핫 정답 벡터에서 정답 클래스만 y=1이므로,
다중 클래스 교차 엔트로피 −Σ yₖ·ln(ŷₖ) 는 결국 <strong>정답 클래스 항 하나</strong>만 남는다.</span>`) : html``유형 E — 여러 건의 교차 엔트로피 평균 [도전]
rG = {
const rng = dojoRng(rG_seed);
const n = 4, ys = [], ps = [], ls = [];
for (let i = 0; i < n; i++) {
const y = dojoInt(rng, 0, 1);
const p = dojoInt(rng, 10, 90) / 100;
ys.push(y); ps.push(p);
ls.push(-Math.log(y === 1 ? p : 1 - p));
}
const sum = ls.reduce((a, b) => a + b, 0);
const worst = ls.indexOf(Math.max(...ls)) + 1;
return { n, ys, ps, ls, sum, bce: Math.round(sum / n * 1000) / 1000, worst };
}dojoProblem(`문제 G — 평균 이진 교차 엔트로피`, `
이진 분류 데이터 4건에 대해 모형이 다음 확률을 출력하였다.
<div style="margin:0.5em 0;">
<table style="border-collapse:collapse;font-size:0.95em;">
<tr style="background:#8C1515;color:#fff;">
<th style="padding:0.25em 0.9em;">i</th>
${rG.ys.map((_, i) => `<th style="padding:0.25em 0.9em;">${i + 1}</th>`).join("")}
</tr>
<tr><td style="padding:0.25em 0.9em;border:1px solid #ddd;"><strong>정답 y</strong></td>
${rG.ys.map(v => `<td style="padding:0.25em 0.9em;border:1px solid #ddd;text-align:center;">${v}</td>`).join("")}</tr>
<tr><td style="padding:0.25em 0.9em;border:1px solid #ddd;"><strong>예측 ŷ</strong></td>
${rG.ps.map(v => `<td style="padding:0.25em 0.9em;border:1px solid #ddd;text-align:center;">${v}</td>`).join("")}</tr>
</table>
</div>
① 전체 <strong>평균 BCE</strong>, ② <strong>손실이 가장 큰 데이터의 번호</strong>를 구하라.`)viewof rG_ans_bce = Inputs.number({ label: "평균 BCE =", step: 0.001 })
viewof rG_ans_worst = Inputs.number({ label: "손실 최대 데이터 번호 =", step: 1 })
viewof rG_grade = Inputs.toggle({ label: "채점", value: false })
viewof rG_show = Inputs.toggle({ label: "풀이 보기", value: false })rG_grade ? dojoResult(
[dojoMark("평균 BCE", dojoOk(rG_ans_bce, rG.bce, 0.0025)),
dojoMark("손실 최대 번호", dojoOk(rG_ans_worst, rG.worst, 0.001))],
dojoOk(rG_ans_bce, rG.bce, 0.0025) && dojoOk(rG_ans_worst, rG.worst, 0.001)
) : html``rG_show ? dojoSolution(`
각 건의 손실 = −ln(정답 클래스에 준 확률):<br>
${rG.ys.map((y, i) => {
const used = y === 1 ? rG.ps[i] : 1 - rG.ps[i];
return ` i=${i + 1}: y=${y}, ŷ=${rG.ps[i]} → 정답 확률 ${used.toFixed(2)}
→ −ln(${used.toFixed(2)}) = <strong>${rG.ls[i].toFixed(3)}</strong>`;
}).join("<br>")}<br>
① 평균 = (${rG.ls.map(v => v.toFixed(3)).join(" + ")}) / 4
= ${rG.sum.toFixed(3)} / 4 = <strong>${rG.bce.toFixed(3)}</strong><br>
② 가장 큰 손실은 <strong>i = ${rG.worst}</strong><br>
<span style="color:#555;">y=0인데 ŷ가 크거나, y=1인데 ŷ가 작은 건이 손실을 지배한다.</span>`) : html``다 풀었다면
- 각 유형에서 연속 3문제를 실수 없이 풀었다면 3주차는 완성이다.
- 막히는 유형이 있으면 Ch03 딥러닝의 학습으로 돌아가라.
- 앞 주차: 2주차 퍼셉트론 | 다음 주차: 4주차 경사하강법