Altifigence Academy

22 / 24 · 实验

实验:检验边界情况

在填充测试数据中,将所有卷积核权重替换为一。首个输出是多少?为什么只测试中心不够?

运行两个文件

用第一个代码块创建 model.mjs,用第二个创建 corner-lab.mjs,然后运行 node corner-lab.mjs。只需要 Node 内置 assert。可通过已安装 Digital Design Studio 支持的流程手动创建并检查这些文件。此处执行证据为 Node 命令,不包括 DDS 仿真、综合或远程项目连接。

model.mjs 提供单批次整数直接卷积、无填充 im2col、最近偶数舍入和无填充 2×2 最大池化。两个空间轴使用共同整数步幅/膨胀率,四个方向独立填充。它检查分组和矩形形状,并拒绝不安全的整数累加器。这不是通用模型交换工具,也不是完整 ONNX 运行时。 输入代码、权重代码和偏置必须是 JavaScript 安全整数(绝对值不超过 9007199254740991)。每项乘积和所有中间部分和也必须保持在该范围内;否则计算将报错并被拒绝。 此整数约定适用于 conv2d 和 dot 的累加,不改变 quantize 和 requantize 的浮点 scale 与舍入规则。

边界测试示例

输入 [1,2,3;4,5,6;7,8,9] 与非对称 2×2 卷积核,在两条路径中都得到 [37,47;67,77]。顶部填充 1、左侧 0、底部 0、右侧 1 时,首个窗口为 [0,0;1,2],得到 11。膨胀率二且无填充时,首个窗口为 [1,3;7,9],得到 1+6+21+36=64。手写发生变化的坐标。

两个通道包含 2 和 3,分组数二,权重为 4 和 5,产生 8 和 15。用同一权重形状但分组数一时,必须抛出错误:此时每个输出需要两个输入通道,却只存储一个卷积核。还应检查真实零编码 128 和两个 int8 截断边界。

运行原始检查,然后暂时反转卷积核索引,观察第一项断言失败,再恢复。错误边界判断拒绝最后有效行,也是有用的变异。移除分组检查应影响形状拒绝测试数据。恢复后保存一次通过运行。

这些测试数据是回归集的起点,并非穷尽。加入多通道 1×1、非零零点填充、负卷积核、奇数池化尺寸、空输出和大偏置。明确每项断言观察哪条约定。综合项目复用此参考来验证完整小型通路。

model.mjs

javascript
// 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

javascript
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');

自己试试

在填充测试数据中,将所有卷积核权重替换为一。首个输出是多少?为什么只测试中心不够?

阅读解释

首个窗口 [0,0;1,2] 的和为三。中心窗口没有越界访问,可能漏掉填充值或顶部/左侧坐标错误。

Lesson files

model.mjs ↓corner-lab.mjs ↓

你的选择适用于此浏览器,可随时在页脚更改。