- Un dtype, une taille fixe. Un tenseur a un type et toutes ses cases font la même taille : float32 = 4 octets, int64 = 8 (p08-02, pas 8). C'est cette uniformité qui rend l'arithmétique d'offset possible — sans elle, pas de pas constants.
- Indexation à partir de 0, et
a:bexclutb.::2prend une case sur deux. Aucune autre convention n'est supposée. - Une somme consomme son indice. Σi xi n'a plus d'indice i : le pas 4 n'est que cette phrase, écrite en formes.
- La loss attend des formes précises.
F.cross_entropyveut des logits (N, C) et des cibles (N,) en indices (p08-03, pas 8). Le pas 8 d'ici est la méthode pour vérifier ça avant de lancer.
La chaîne
Le décor : ce n'est pas un cube
On écrit t = arange(24).reshape(2, 3, 4) et on pense à un pavé de 2 × 3 × 4. La machine, elle, a alloué 24 cases à la suite et retenu trois nombres pour savoir s'y déplacer. Le pavé n'existe nulle part.
Toutes les surprises de la suite — view qui refuse, une division qui normalise les colonnes — viennent de cet écart entre l'image mentale et l'objet réel.
« Il n'y a pas de cube en mémoire : il y a un ruban de 24 cases et une règle pour y naviguer. »
Le ruban, en float32 : 24 cases × 4 octets = 96 octets contigus.
| objet | ce que c'est |
|---|---|
le ruban (storage) | 24 cases, jamais réorganisées |
shape | (2, 3, 4) — métadonnée |
stride | (12, 4, 1) — métadonnée |
storage_offset | 0 — où commence la vue |
Trois tenseurs différents peuvent partager le même ruban. Ce sont trois jeux de métadonnées, pas trois copies.
Le ruban et les pas tronc
La forme dit combien d'axes et de quelle taille. Les pas (strides) disent de combien de cases on avance quand un indice augmente de 1. Lire une case, c'est une somme :
En row-major, chaque pas est le produit des tailles à sa droite : s2 = 1, s1 = 4, s0 = 3·4 = 12. Le dernier axe est donc contigu, le premier saute un bloc entier.
« La mémoire est un ruban, donc une position multi-indice est un offset linéaire, donc chaque axe a un pas, donc un tenseur est une forme plus des pas posés sur un ruban. »
| axe | taille | pas | +1 sur cet indice saute… |
|---|---|---|---|
| 0 | 2 | 12 | un bloc de 3 × 4 cases |
| 1 | 3 | 4 | une ligne de 4 cases |
| 2 | 4 | 1 | une case — contigu |
t[1, 2, 3] → 1·12 + 2·4 + 3 = 23 : la dernière case du ruban. Aucune recherche, une multiplication-addition.
t.stride() vaut (12, 4, 1) en PyTorch — en éléments. a.strides vaut (48, 16, 4) en NumPy float32 — en octets. Même ruban, même géométrie, un facteur itemsize = 4.En haut la grille logique (chaque case porte la valeur qu'elle lit), en bas le ruban. Le curseur parcourt la vue dans l'ordre logique (dernier axe d'abord) et le trait rouge montre quelle case du ruban est lue. Sur une vue contiguë la case lue avance d'un cran à chaque fois ; sur transpose(0,1) elle saute — c'est exactement ce que view refuse. Seul .contiguous() change le ruban : il en fabrique un neuf, dans l'ordre de lecture.
Vue = mêmes cases, autres métadonnées tronc
reshape compatible, view, transpose, t[:, ::2], t[None] ne touchent pas au ruban : ils posent d'autres métadonnées dessus. C'est gratuit, et modifier la vue modifie l'original.
view exige en plus que le ruban se relise d'un trait : il refuse dès que la vue n'est plus contiguë. transpose échange deux pas et casse cet ordre ; .contiguous() recopie dans un ruban neuf, et reshape le fait pour vous — silencieusement.
« Changer la forme sans copier est possible tant que le ruban se relit dans l'ordre, donc transpose casse cet ordre, donc view refuse et reshape copie. »
| opération | shape | strides | contigu | view(-1) |
|---|---|---|---|---|
t | (2,3,4) | (12,4,1) | oui | ok |
transpose(0,1) | (3,2,4) | (4,12,1) | non | refuse |
t[:,::2] | (2,2,4) | (12,8,1) | non | refuse |
reshape(6,4) | (6,4) | (4,1) | oui | ok |
t[:, ::2] lit une ligne sur deux : pas doublé (4 → 8), zéro copie, contenu 0–3, 8–11, 12–15, 20–23.
x.view(-1, k) : le −1 est déduit (24 cases, k = 4 ⇒ 6 lignes), mais il n'excuse rien — la contiguïté reste exigée. Après un transpose, écrire .contiguous().view(-1, k) ou .reshape(-1, k).
Réduire consomme un axe
sum(axis=k), mean, max, argmax retirent l'axe k : le résultat a une dimension de moins et les axes suivants se décalent d'un cran vers la gauche.
keepdim=True laisse à sa place un axe de taille 1. Vide de contenu, plein de sens : c'est lui qui dira au broadcasting sur quel axe se recaler — indispensable dès qu'on va rediviser (pas 6).
« Réduire un axe le fait disparaître, donc le résultat ne s'aligne plus sur l'original à cet endroit, donc on garde un axe de taille 1 pour rediviser. »
| expression | shape | contenu |
|---|---|---|
t.sum(1) | (2, 4) | 12, 15, 18, 21 ; 48, 51, 54, 57 |
t.sum(1, keepdim=True) | (2, 1, 4) | les mêmes, un axe en plus |
t.sum((0, 1)) | (4,) | 60, 66, 72, 78 |
t.sum() | () — scalaire | 276 |
Vérification : 12 = 0 + 4 + 8, les trois cases d'indice k = 0 dans le premier bloc. Et 276 = 23·24/2, la somme du ruban entier.
La forme (2, 4) et la forme (2, 1, 4) contiennent les mêmes huit nombres. Elles ne se comportent pas pareil une ligne plus bas — c'est tout le pas 6.
Broadcasting : aligner par la droite, étirer les 1 tronc
Deux formes sont compatibles si, alignées sur leur dernier axe, chaque paire est égale ou contient un 1. Les axes manquants à gauche sont complétés par des 1.
Un 1 est étiré : il est relu avec un pas de 0, la même case servie autant de fois qu'il faut. Rien n'est copié — le broadcasting est du stride, pas de la mémoire.
« L'alignement part de la droite, donc un vecteur de longueur 4 s'applique au dernier axe, donc pour viser un autre axe il faut placer des 1 explicites. »
| second opérande | complété | verdict |
|---|---|---|
| (4,) | (1, 1, 4) | ✓ — s'applique au dernier axe |
| (3,) | (1, 1, 3) | erreur — 4 ≠ 3 sur le dernier axe |
| (3, 1) | (1, 3, 1) | ✓ — vise bien l'axe du milieu |
| (2, 1, 4) | (2, 1, 4) | ✓ — l'axe 1 est étiré ×3 |
Le cas (3,) est instructif : l'intention était « une valeur par ligne », mais l'alignement par la droite vise les colonnes. Ici les tailles diffèrent, donc ça plante. Le pas 6 montre le cas où elles coïncident — et où ça ne plante pas.
Les deux formes sont collées à droite. Chaque colonne reçoit un ✓ (tailles égales, ou un 1 qui s'étire) ou un ✗. Les axes en pointillé sont les 1 implicites des formes plus courtes : (4,) devient (1, 1, 4). Baisse le dernier axe de B à 3 avec A à 4 pour voir le ✗ ; mets B à (1, 3, 1) pour viser l'axe du milieu. La ligne du bas donne la forme résultat et marque les axes lus avec un pas de 0.
Le bug silencieux tronc
M.sum(1) a perdu l'axe 1 : sa forme est (3,), pas (3, 1). Alignée par la droite sur (3, 3), elle se pose donc sur les colonnes. M / M.sum(1) divise la colonne j par la somme de la ligne j : formes compatibles, aucune erreur levée, résultat faux.
Avec keepdim=True la forme est (3, 1) : le 1 est sur le dernier axe et s'étire sur les colonnes, l'axe 3 se pose sur les lignes. Chaque ligne est divisée par sa propre somme.
« La somme sur les lignes a perdu son axe, donc elle s'aligne sur les colonnes, donc chaque colonne est divisée par la mauvaise somme, donc on garde l'axe. »
| ligne | M / M.sum(1) ⇒ Σ | keepdim=True ⇒ Σ |
|---|---|---|
| 0 | 0,167 ; 0,133 ; 0,125 ⇒ 0,425 | 0,167 ; 0,333 ; 0,5 ⇒ 1 |
| 1 | 0,667 ; 0,333 ; 0,25 ⇒ 1,25 | 0,267 ; 0,333 ; 0,4 ⇒ 1 |
| 2 | 1,167 ; 0,533 ; 0,375 ⇒ 2,075 | 0,292 ; 0,333 ; 0,375 ⇒ 1 |
Sans keepdim : 1/6 ; 2/15 ; 3/24 sur la première ligne — trois dénominateurs différents. Les lignes somment à 0,425, 1,25 et 2,075 au lieu de 1 : ce sont des « probabilités » qui n'en sont pas. Rien n'a planté.
C'est exactement la ligne P = N / N.sum(1, keepdim=True) d'une table de bigrammes, et la même erreur dans un softmax ou une batchnorm écrits à la main. Le symptôme n'est jamais une exception : c'est une loss qui refuse de descendre.
À gauche M = 1…9 et le vecteur des sommes de lignes (6, 15, 24), dessiné là où le broadcasting le pose : en dessous, sur les colonnes, quand sa forme est (3,) ; à gauche, sur les lignes, quand elle est (3, 1). À droite le résultat et ses sommes de lignes — vertes à 1, rouges sinon. Le curseur choisit la ligne surlignée : suis-la jusqu'aux diviseurs pour voir lesquels l'ont divisée.
Masques et indexation : qui copie
Une vue doit pouvoir se décrire par une forme, des pas et un offset. Le slicing et t[:, None] le peuvent : ce sont des vues. Un choix arbitraire de cases, lui, ne s'écrit pas ainsi.
D'où la règle : l'indexation avancée — masque booléen t[mask], liste d'indices t[idx] — copie toujours. Un masque de la forme du tenseur ne peut pas rendre un rectangle : il aplatit et rend un 1-D.
« Une vue se décrit par une forme et des pas, donc un choix arbitraire de cases ne s'écrit pas ainsi, donc l'indexation avancée copie, et un masque plein aplatit. »
| expression | shape | vue ou copie |
|---|---|---|
t[:, 0] | (2, 4) | vue — strides (12, 1) |
t[:, None] | (2, 1, 3, 4) | vue — un axe de taille 1 inséré |
t[t % 7 == 0] | (4,) | copie — aplati : 0, 7, 14, 21 |
t[[1, 0]] | (2, 3, 4) | copie — indices avancés |
Conséquence pratique : t[mask] += 1 modifie bien t (PyTorch réécrit à travers l'index), mais u = t[mask] ; u += 1 ne modifie que la copie. Avec une vue, les deux modifient l'original.
Lire une opération sur les axes
La méthode, avant de lancer : écrire les formes des deux opérandes ; les aligner par la droite ; marquer les axes consommés par une réduction et les 1 qui vont s'étirer ; comparer à la forme attendue.
einsum fait la même chose avec des noms : chaque lettre est un axe, une lettre répétée à gauche et absente à droite est sommée, une lettre présente des deux côtés est portée.
einsum('bij,bjk->bik') : b porté, j sommé, i et k gardés« Une erreur d'axes ne lève pas d'exception, donc on écrit les formes avant de lancer, donc on aligne par la droite et on marque les axes consommés, donc la forme attendue se vérifie sur le papier. »
Trois lignes écrites, le bug est vu sans exécuter.
einsum, chiffré. A de forme (8, 4, 5), B de forme (8, 5, 6) : einsum('bij,bjk->bik') rend (8, 4, 6). L'axe j = 5 est sommé, b = 8 est porté ; 8 · 4 · 5 · 6 = 960 multiplications. Les mêmes formes en @ font la même chose sans les noms — et sans la relecture.
Où ça casse casse
Le même mécanisme qui rend tout gratuit rend tout silencieux : partage de mémoire et compatibilité de formes ne préviennent jamais.
« Une vue partage le ruban et le broadcasting accepte tout ce qui s'aligne, donc les deux erreurs typiques ne lèvent pas d'exception, donc la seule défense est d'écrire les formes et de savoir qui partage quoi. »
- Vue modifiée = original modifié.
y = x.view(...)puisy += 1changex. C'est l'avantage (zéro copie) et le piège (aliasing) : dès qu'on veut un tenseur indépendant,.clone()— pas.reshape(), qui ne copie que parfois. - Copies cachées.
reshapesur non contigu, indexation avancée,.Tsuivi d'unviewimpossible,torch.cat: le coût mémoire double sans avertissement. Sur un batch d'activations, c'est la différence entre tenir et ne pas tenir. - Réduction sans
keepdimsuivie d'une division. Le pas 6, et il se produit chaque fois qu'on normalise à la main : softmax, batchnorm, table de bigrammes. Formes valides, résultat faux, loss qui stagne. - Ordre des axes convention-dépendant. (B, T, C) pour un transformeur, (B, C, T) pour une convolution 1-D. Un
transposeoublié passe sans erreur dès que deux tailles coïncident — B = C sur un petit test, et le bug n'apparaît qu'à l'échelle.
Résumé
- Tenseur = forme + pas posés sur un ruban ; offset = Σ indice × pas ; en row-major le dernier axe est contigu.
- Vue = mêmes cases (reshape compatible, view, transpose, slicing,
None) ; copie = ruban neuf (contiguous, indexation avancée, reshape sur non contigu). transposecasse la contiguïté ⇒viewrefuse ;.contiguous()ou.reshape()recopient.- Réduire consomme un axe ;
keepdim=Truele garde en taille 1 pour pouvoir rediviser. - Broadcasting : aligner par la droite, chaque paire égale ou 1 ; le 1 s'étire avec un pas de 0, sans copie.
M / M.sum(1)est valide et faux ;keepdim=Truerépare. Écrire les formes avant de lancer.
Chaîne verbalisée — une prise, à voix haute
- Que valent les pas de
arange(24).reshape(2,3,4), et l'offset de[1,2,3]?(12, 4, 1) — produit des tailles à droite. Offset = 1·12 + 2·4 + 3 = 23, la dernière case. - Pourquoi
transposepuisviewéchoue-t-il ?Transpose échange deux pas : (4, 12, 1) ne décroît plus, la vue n'est plus contiguë.viewexige de relire le ruban d'un trait..contiguous()ou.reshape()recopient. x[:, ::2]: vue ou copie ?Vue : pas doublé sur l'axe 1 (4 → 8), shape (2, 2, 4), zéro octet copié — et écrire dedans écrit dansx.- La règle du broadcasting en une phrase.Aligner les formes par la droite, compléter à gauche par des 1 ; chaque paire égale ou contenant un 1 ; le 1 s'étire avec un pas de 0, sans copie.
- Que fait
M / M.sum(1)sur une 3×3 ?La somme a la forme (3,), elle s'aligne sur les colonnes : la colonne j est divisée par la somme de la ligne j. Valide, faux — les lignes somment à 0,425 ; 1,25 ; 2,075.keepdim=Truedonne (3, 1) et répare. sum(1)sur (2, 3, 4) : quelle forme ? et aveckeepdim?(2, 4) — l'axe 1 est consommé et les suivants se décalent. Aveckeepdim=True: (2, 1, 4), mêmes nombres, place gardée.
Ponts et cartes
F.cross_entropy attend (N, C) et (N,) : le pas 8 d'ici est la méthode qui vérifie ces formes avant l'appel
p08-01, pas 7 — accumulationle += de la backprop écrit à travers des vues : même partage de mémoire, vu du côté des gradients
p04-01 — une matrice est une actionl'autre lecture, complémentaire : ici une matrice est un rectangle posé sur un ruban, là c'est une transformation de l'espace
c00 — TAP, invariant, variantla suite : écrire l'invariant d'une boucle, c'est le même geste que vérifier une forme avant de lancer
python::tenseurs (ruban, shape, storage_offset, vue vs copie) · python::strides (offset = Σ indice × pas, row-major, contiguïté, view vs reshape) · python::broadcasting (alignement par la droite, 1 étiré à pas 0, cas compatibles et incompatibles) · python::keepdim (un axe consommé, (2, 4) vs (2, 1, 4), M / M.sum(1)).
Une carte qui résiste après cette chaîne est une carte à refondre, pas une section à relire.