22 / 24 · 실습
실습: 경계 조건을 깨뜨려 보기
위 패딩 예제에서 커널을 모두 1로 바꾸면 첫 출력은 무엇이며 왜 가운데 결과만 검사하면 안 되나요?
내용은 무료로 볼 수 있습니다. 수강 신청하면 학습 기록을 저장할 수 있어요.
두 파일로 실행하기
새 폴더에 model.mjs와 corner-lab.mjs를 만듭니다. 먼저 아래 첫 코드 블록을 model.mjs로 저장하고 둘째 블록을 corner-lab.mjs로 저장한 뒤 node corner-lab.mjs를 실행합니다. Node.js 내장 assert만 사용합니다. 설치된 Digital Design Studio에서 지원되는 파일 만들기·열기 흐름이 있다면 같은 두 파일을 수동으로 생성해 검토할 수 있습니다. 이 실습의 실행 근거는 Node 명령이며 DDS 시뮬레이션·합성·원격 프로젝트 연결은 포함되지 않습니다.
model.mjs는 한 배치,정수 누적의 직접 합성곱과 패딩 없는 im2col,짝수 반올림,2×2 최대 풀링을 제공합니다. stride와 dilation은 두 공간 축에 같은 정수를 사용하며 네 방향 패딩은 따로 지정합니다. 그룹 조건과 배열 모양을 검사하고 안전한 JavaScript 정수 범위를 벗어난 누적은 거부합니다. 일반 모델 교환 도구나 완전한 ONNX 실행기라는 의미는 없습니다. 입력 코드·가중치 코드와 바이어스는 JavaScript 안전 정수(절댓값 9007199254740991 이하)여야 합니다. 각 곱과 모든 중간 부분합도 이 범위를 유지해야 하며, 벗어나면 계산을 오류로 거부합니다. 이 정수 계약은 conv2d와 dot의 누적에 적용하며, quantize와 requantize의 부동소수점 scale·반올림 규칙은 바꾸지 않습니다.
기대값을 먼저 계산하기
입력 [1,2,3;4,5,6;7,8,9]와 비대칭 2×2 커널의 direct와 im2col 결과는 [37,47;67,77]입니다. 위 패딩 1,왼쪽 0,아래 0,오른쪽 1의 첫 창은 [0,0;1,2]여서 0+0+3+8=11입니다. dilation 2를 적용한 패딩 없는 첫 창은 [1,3;7,9]이며 1+6+21+36=64입니다. 같은 입력이라도 접근 좌표가 바뀌는 이유를 손으로 적어 보세요.
채널 두 개 [2],[3]을 groups=2로 나누고 각 그룹 가중치를 4,5로 두면 출력은 8,15입니다. groups=1로 같은 가중치 배열을 사용하면 출력마다 입력 채널 두 개가 필요한데 가중치에는 하나만 있어 오류를 내야 합니다. 모양이 틀렸을 때 부분 결과를 조용히 내는 것은 올바른 복구가 아닙니다. 양자화 실수 0의 코드 128과 int8 양·음 포화도 별도로 확인합니다.
의도적 오류를 관찰하기
우선 원래 코드의 검사가 모두 통과하는지 확인합니다. 다음에 직접 모델의 커널 인덱스를 뒤집도록 잠깐 수정해 첫 assertion을 실패시키고 되돌립니다. 범위 검사에서 아래 끝을 height 대신 height−1로 제한하면 마지막 유효 행이 빠지는 오류를 찾을 수 있습니다. 잘못된 그룹 조건 검사를 제거했을 때 모양 거부 테스트가 어떻게 변하는지도 확인하세요. 원본으로 되돌린 뒤 다시 통과 결과를 저장합니다.
이 fixtures는 회귀 검증의 출발점이며 모든 경우를 열거한 것은 아닙니다. 다채널 1×1,비영 zero point의 padding,음수 커널,홀수 풀링 크기,0출력 모양과 큰 바이어스도 추가할 수 있습니다. 검사 수를 늘리는 것보다 어떤 규칙을 감시하는지 분명히 적는 것이 중요합니다. 다음 캡스톤은 이 기준 모델을 재사용해 작은 전체 경로를 연결합니다.
model.mjs
// Small, dependency-free teaching model. Layout: X[C][H][W], W[M][C/group][R][S].
export function outputSize(size, kernel, stride = 1, padBefore = 0, padAfter = 0, dilation = 1) {
if (![size, kernel, stride, dilation].every(v => Number.isInteger(v) && v > 0) ||
![padBefore, padAfter].every(v => Number.isInteger(v) && v >= 0)) throw new RangeError('Invalid shape');
return Math.max(0, Math.floor((size + padBefore + padAfter - dilation * (kernel - 1) - 1) / stride) + 1);
}
export function roundEven(value) {
if (!Number.isFinite(value)) throw new RangeError('Finite number required');
const floor = Math.floor(value), fraction = value - floor;
return fraction < 0.5 ? floor : fraction > 0.5 ? floor + 1 : floor % 2 === 0 ? floor : floor + 1;
}
export const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
export function quantize(value, scale, zeroPoint = 0, low = -128, high = 127) {
if (!(scale > 0) || !Number.isFinite(scale)) throw new RangeError('Positive finite scale required');
return clamp(roundEven(value / scale) + zeroPoint, low, high);
}
export const dequantize = (value, scale, zeroPoint = 0) => (value - zeroPoint) * scale;
export function requantize(acc, multiplier, zeroPoint = 0, low = -128, high = 127) {
return clamp(roundEven(acc * multiplier) + zeroPoint, low, high);
}
export function conv2d(x, weights, {stride = 1, pads = [0, 0, 0, 0], dilation = 1, groups = 1, bias = null} = {}) {
const channels = x.length, height = x[0]?.length, width = x[0]?.[0]?.length;
const outputs = weights.length, r = weights[0]?.[0]?.length, s = weights[0]?.[0]?.[0]?.length;
if (!Number.isInteger(groups) || groups < 1 || channels % groups || outputs % groups) throw new RangeError('Invalid groups');
if (pads.length !== 4) throw new RangeError('Four explicit pads required');
if (!height || !width || !r || !s || x.some(c => c.length !== height || c.some(row => row.length !== width)) ||
weights.some(m => m.length !== channels / groups || m.some(c => c.length !== r || c.some(row => row.length !== s))))
throw new RangeError('Ragged or incompatible tensor');
if (bias && bias.length !== outputs) throw new RangeError('Bias shape');
if (x.flat(2).some(value => !Number.isSafeInteger(value)) ||
weights.flat(3).some(value => !Number.isSafeInteger(value)) ||
(bias && bias.some(value => !Number.isSafeInteger(value))))
throw new RangeError('Safe integer operands and bias required');
const [top, left, bottom, right] = pads;
const outH = outputSize(height, r, stride, top, bottom, dilation);
const outW = outputSize(width, s, stride, left, right, dilation);
const y = Array.from({length: outputs}, () => Array.from({length: outH}, () => Array(outW).fill(0)));
for (let m = 0; m < outputs; m++) for (let h = 0; h < outH; h++) for (let w = 0; w < outW; w++) {
let acc = bias?.[m] ?? 0;
const firstC = Math.floor(m / (outputs / groups)) * (channels / groups);
for (let c = 0; c < channels / groups; c++) for (let kh = 0; kh < r; kh++) for (let kw = 0; kw < s; kw++) {
const ih = h * stride - top + kh * dilation, iw = w * stride - left + kw * dilation;
if (ih >= 0 && ih < height && iw >= 0 && iw < width) {
const product = x[firstC + c][ih][iw] * weights[m][c][kh][kw];
if (!Number.isSafeInteger(product)) throw new RangeError('Unsafe integer product');
const next = acc + product;
if (!Number.isSafeInteger(next)) throw new RangeError('Unsafe integer partial sum');
acc = next;
}
}
if (!Number.isSafeInteger(acc)) throw new RangeError('This teaching model requires safe integer accumulators');
y[m][h][w] = acc;
}
return y;
}
export function im2col(x, kernelH, kernelW) {
const height = x[0].length, width = x[0][0].length, rows = [];
for (let h = 0; h <= height - kernelH; h++) for (let w = 0; w <= width - kernelW; w++) {
const row = [];
for (const channel of x) for (let r = 0; r < kernelH; r++) for (let s = 0; s < kernelW; s++) row.push(channel[h + r][w + s]);
rows.push(row);
}
return rows;
}
export function dot(a, b) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
throw new RangeError('Incompatible dot vectors');
let acc = 0;
for (let i = 0; i < a.length; i++) {
if (!Number.isSafeInteger(a[i]) || !Number.isSafeInteger(b[i]))
throw new RangeError('Safe integer dot operands required');
const product = a[i] * b[i];
if (!Number.isSafeInteger(product)) throw new RangeError('Unsafe integer dot product');
const next = acc + product;
if (!Number.isSafeInteger(next)) throw new RangeError('Unsafe integer dot partial sum');
acc = next;
}
return acc;
}
export function maxPool2x2(matrix) {
const output = [];
for (let h = 0; h + 1 < matrix.length; h += 2) {
const row = [];
for (let w = 0; w + 1 < matrix[0].length; w += 2)
row.push(Math.max(matrix[h][w], matrix[h][w + 1], matrix[h + 1][w], matrix[h + 1][w + 1]));
output.push(row);
}
return output;
}corner-lab.mjs
import assert from 'node:assert/strict';
import {conv2d, im2col, dot, outputSize, quantize, requantize} from './model.mjs';
const x = [[[1, 2, 3], [4, 5, 6], [7, 8, 9]]], w = [[[[1, 2], [3, 4]]]];
assert.deepEqual(conv2d(x, w), [[[37, 47], [67, 77]]]);
assert.deepEqual(im2col(x, 2, 2).map(row => dot(row, [1, 2, 3, 4])), [37, 47, 67, 77]);
assert.equal(conv2d(x, w, {pads:[1, 0, 0, 1]})[0][0][0], 11);
assert.equal(conv2d(x, w, {dilation:2})[0][0][0], 64);
assert.equal(outputSize(5, 3, 2, 1, 1, 1), 3);
assert.equal(outputSize(5, 3, 2, 1, 1, 2), 2);
assert.equal(quantize(0, 0.25, 128, 0, 255), 128);
assert.equal(requantize(300, 1), 127);
assert.equal(requantize(-300, 1), -128);
const channels = [[[2]], [[3]]], groupWeights = [[[[4]]], [[[5]]]];
assert.deepEqual(conv2d(channels, groupWeights, {groups:2}), [[[8]], [[15]]]);
assert.throws(() => conv2d(channels, groupWeights), /incompatible/);
// Guard every arithmetic step: cancellation must not hide lost precision.
const safe = Number.MAX_SAFE_INTEGER, pairWeights = [[[[1, 1]]]];
assert.throws(() => conv2d([[[2, -2]]], pairWeights, {bias:[safe]}), /partial sum/);
assert.throws(() => conv2d([[[-2, 2]]], pairWeights, {bias:[-safe]}), /partial sum/);
assert.throws(() => conv2d([[[safe + 1]]], [[[[1]]]]), /operands/);
assert.throws(() => conv2d([[[0]]], [[[[safe + 1]]]]), /operands/);
assert.throws(() => conv2d([[[-1]]], [[[[1]]]], {bias:[safe + 1]}), /bias/);
assert.throws(() => conv2d([[[0.5]]], [[[[2]]]]), /operands/);
assert.throws(() => conv2d([[[100000000]]], [[[[100000000]]]]), /product/);
// Safe boundary arithmetic and zero output dimensions remain supported.
assert.deepEqual(conv2d([[[2, -2]]], pairWeights, {bias:[safe - 2]}), [[[safe - 2]]]);
assert.deepEqual(conv2d([[[1]]], [[[[1, 1], [1, 1]]]]), [[]]);
assert.deepEqual(conv2d([[[1]]], pairWeights), [[[]]]);
// The independent patch-dot path uses the same integer safety contract.
assert.throws(() => dot([safe, 2, -2], [1, 1, 1]), /partial sum/);
assert.throws(() => dot([safe + 1], [0]), /operands/);
assert.throws(() => dot([100000000], [100000000]), /product/);
assert.throws(() => dot([1], [1, 2]), /vectors/);
assert.equal(dot([safe - 2, 2, -2], [1, 1, 1]), safe - 2);
assert.equal(dot([], []), 0);
console.log('corner-lab: 27 checks passed');직접 생각해 보기
위 패딩 예제에서 커널을 모두 1로 바꾸면 첫 출력은 무엇이며 왜 가운데 결과만 검사하면 안 되나요?
해설 보기
첫 창 [0,0;1,2]의 합은 3입니다. 가운데 창에는 범위 밖 접근이 없으므로 패딩의 값이나 top/left 좌표 오류를 관찰하지 못할 수 있습니다.