FichesCarte › Partie 08 · Calcul, numérique, tenseurs › chaîne 04

Un tenseur est un ruban, une forme et des pas

Pourquoi reshape est gratuit et transpose casse view, pourquoi M / M.sum(1) normalise les mauvaises lignes sans lever d'erreur, et comment lire une opération sur les axes sans se tromper. L'idée unique : un tenseur est une forme et des pas posés sur un ruban de mémoire — les métadonnées bougent, le ruban presque jamais. Fil rouge : arange(24).reshape(2, 3, 4), ses pas (12, 4, 1), et la matrice 1…9 qu'on normalise deux fois — une fois faux, une fois juste.

Ce que cette chaîne suppose acquis
  • 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:b exclut b. ::2 prend 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_entropy veut 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.
Hypothèses posées
H1La mémoire est un ruban unidimensionnel de cases typées. La forme (shape) et les pas (strides) sont des métadonnées posées dessus, pas le contenu. H2PyTorch et NumPy suivent les mêmes règles ici. Seule l'unité change : les pas se comptent en éléments côté PyTorch, en octets côté NumPy. H3Ordre row-major (ordre C) : le dernier axe est contigu, son pas vaut 1. Fortran / channels-last existent et ne sont pas traités ici.

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. »

Application — le fil rouge

Le ruban, en float32 : 24 cases × 4 octets = 96 octets contigus.

ruban : 0 · 1 · 2 · 3 · 4 · 5 · … · 21 · 22 · 23
objetce 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_offset0 — 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 :

offset(i, j, k) = i·s0 + j·s1 + k·s2

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. »

Application — (2, 3, 4)
axetaillepas+1 sur cet indice saute…
0212un bloc de 3 × 4 cases
134une ligne de 4 cases
241une case — contigu

t[1, 2, 3] → 1·12 + 2·4 + 3 = 23 : la dernière case du ruban. Aucune recherche, une multiplication-addition.

Unités. 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.
Figure 1 — le ruban ne bouge pas, les pas changent

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. »

Application — trois vues du même ruban
opérationshapestridescontiguview(-1)
t(2,3,4)(12,4,1)ouiok
transpose(0,1)(3,2,4)(4,12,1)nonrefuse
t[:,::2](2,2,4)(12,8,1)nonrefuse
reshape(6,4)(6,4)(4,1)ouiok

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. »

Application — réductions de t
expressionshapecontenu
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()() — scalaire276

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.

(2, 3, 4) + (4,) → (2, 3, 4) et (1, 1, 4) → ✓    résultat (2, 3, 4)

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. »

Application — quatre essais sur (2, 3, 4)
second opérandecomplé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.

Figure 2 — la règle en une image

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. »

Application — M = 1…9, sommes de lignes 6, 15, 24
ligneM / M.sum(1) ⇒ Σkeepdim=True ⇒ Σ
00,167 ; 0,133 ; 0,125 ⇒ 0,4250,167 ; 0,333 ; 0,5 ⇒ 1
10,667 ; 0,333 ; 0,25 ⇒ 1,250,267 ; 0,333 ; 0,4 ⇒ 1
21,167 ; 0,533 ; 0,375 ⇒ 2,0750,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.

Figure 3 — le bug qui ne plante pas

À 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. »

Application — sur t (2, 3, 4)
expressionshapevue 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. »

Application — la vérification du pas 6, au stylo
M (3, 3) M.sum(1) (3,) ← axe 1 consommé aligné : (3, 3) ( 3) ← les colonnes voulu : (3, 1) ← une par ligne

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. »

Quatre limites
  • Vue modifiée = original modifié. y = x.view(...) puis y += 1 change x. 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. reshape sur non contigu, indexation avancée, .T suivi d'un view impossible, 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 keepdim suivie 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 transpose oublié 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é

À retenir
  1. Tenseur = forme + pas posés sur un ruban ; offset = Σ indice × pas ; en row-major le dernier axe est contigu.
  2. Vue = mêmes cases (reshape compatible, view, transpose, slicing, None) ; copie = ruban neuf (contiguous, indexation avancée, reshape sur non contigu).
  3. transpose casse la contiguïté ⇒ view refuse ; .contiguous() ou .reshape() recopient.
  4. Réduire consomme un axe ; keepdim=True le garde en taille 1 pour pouvoir rediviser.
  5. Broadcasting : aligner par la droite, chaque paire égale ou 1 ; le 1 s'étire avec un pas de 0, sans copie.
  6. M / M.sum(1) est valide et faux ; keepdim=True répare. Écrire les formes avant de lancer.
« Un tenseur est une forme et des pas posés sur un ruban de mémoire ; reshape, transpose et slicing ne déplacent rien, ils changent les pas, ce qui rend view gratuit et le casse dès que le ruban ne se relit plus dans l'ordre. Le broadcasting aligne par la droite et étire les 1 sans copier ; c'est pourquoi une somme sans keepdim, divisée ensuite, normalise les colonnes au lieu des lignes sans lever d'erreur. »

Chaîne verbalisée — une prise, à voix haute

6 maillons · clique pour révéler après avoir dit
  1. 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.
  2. Pourquoi transpose puis view échoue-t-il ?
    Transpose échange deux pas : (4, 12, 1) ne décroît plus, la vue n'est plus contiguë. view exige de relire le ruban d'un trait. .contiguous() ou .reshape() recopient.
  3. 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 dans x.
  4. 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.
  5. 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=True donne (3, 1) et répare.
  6. sum(1) sur (2, 3, 4) : quelle forme ? et avec keepdim ?
    (2, 4) — l'axe 1 est consommé et les suivants se décalent. Avec keepdim=True : (2, 1, 4), mêmes nombres, place gardée.