22 / 24 · Travaux pratiques
TP : mettre à l’épreuve les cas limites
Dans le jeu de remplissage, remplacez tous les poids du noyau par un. Quelle est la première sortie et pourquoi un test limité au centre est-il insuffisant ?
Les leçons sont gratuites. Inscrivez-vous pour enregistrer vos progrès.
Exécuter deux fichiers
Créez model.mjs depuis le premier bloc de code et corner-lab.mjs depuis le second, puis exécutez node corner-lab.mjs. Seul assert intégré à Node est nécessaire. Vous pouvez créer et examiner manuellement ces fichiers dans un parcours pris en charge de Digital Design Studio installé. La preuve d’exécution est ici la commande Node ; elle n’inclut ni simulation DDS, synthèse ni connexion à un projet distant.
model.mjs fournit une convolution directe entière à un lot, im2col sans remplissage, l’arrondi au plus proche vers le pair et le regroupement maximal 2×2 sans remplissage. Il utilise un pas/une dilatation entiers communs aux deux axes spatiaux, avec quatre remplissages indépendants. Il vérifie groupes et dimensions rectangulaires et rejette les accumulateurs entiers non sûrs. Ce n’est ni un outil général d’échange de modèles ni un moteur ONNX complet. Les codes d’entrée, les codes de poids et le biais doivent être des entiers sûrs JavaScript (valeur absolue au plus 9007199254740991). Chaque produit et chaque somme partielle intermédiaire doit aussi rester dans cette plage ; sinon, le calcul est rejeté avec une erreur. Ce contrat entier s’applique à l’accumulation dans conv2d et dot ; il ne remplace pas les règles d’échelle et d’arrondi en virgule flottante de quantize et requantize.
Jeux aux limites détaillés
L’entrée [1, 2, 3;4, 5, 6;7, 8, 9] avec un noyau asymétrique 2×2 donne [37, 47;67, 77] par les deux parcours. Avec les remplissages haut 1, gauche 0, bas 0, droite 1, la première fenêtre est [0, 0;1, 2], donnant 11. Avec une dilatation de deux sans remplissage, la première fenêtre est [1, 3;7, 9], donnant 1+6+21+36=64. Écrivez à la main les coordonnées modifiées.
Deux canaux contenant2 et 3 avec deux groupes et les poids 4 et 5produisent 8 et 15. Réutiliser ces dimensions de poids avec un groupe doit lever une exception : chaque sortie nécessite alors deux canaux d’entrée mais ne possède qu’un noyau stocké. Vérifiez aussi le code de zéro réel 128 et les deux limites d’écrêtage int8.
Exécutez les vérifications originales, puis inversez temporairement les indices du noyau et observez l’échec de la première assertion. Rétablissez-les. Rejeter la dernière ligne valide avec un test de limite incorrect est une autre mutation utile. Supprimer les vérifications de groupes doit affecter le jeu de rejet de dimensions. Enregistrez une exécution réussie après restauration.
Ces jeux amorcent un ensemble de régression ; ils ne sont pas exhaustifs. Ajoutez multicanal 1×1, remplissage à point zéro non nul, noyaux négatifs, taille impaire de regroupement, sortie vide et grand biais. Indiquez le contrat observé par chaque assertion. Le projet final réutilise cette référence pour la petite chaîne complète.
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');Essayez vous-même
Dans le jeu de remplissage, remplacez tous les poids du noyau par un. Quelle est la première sortie et pourquoi un test limité au centre est-il insuffisant ?
Lire l’explication
La première fenêtre [0, 0;1, 2] totalise trois. Une fenêtre centrale n’a aucun accès hors limites et peut manquer les erreurs de valeur de remplissage ou de coordonnées haut/gauche.