Pour faire une multiplication matricielle, il nous faut deux matrices, appelées ici A et B.

Pour que cette multiplication soit possible, il faut que les dimensions "du milieu" (celles qui se rencontrent dans le calcul) soient les mêmes. Soient deux matrices :

A de taille m × n : Cela signifie que A a m lignes et n colonnes.

B de taille n × p : Cela signifie que B a n lignes et p colonnes.

(m x n) x (n x p) = (m x p)

Pour pouvoir multiplier A × B, le nombre de colonnes de A (n) doit être égal au nombre de lignes de B (n).

Le résultat de la multiplication est une matrice C de taille m × p, c'est-à-dire m lignes et p colonnes.

Implémentation en code :

from random import randint, seed

def creer_matrice_aleatoire(n_lignes, n_colonnes, low=-5, high=5):
  matrix = []
  for _ in range(n_lignes):
    ligne = []
    for _ in range(n_colonnes):
        nombre_aleatoire = randint(low, high)
        ligne.append(nombre_aleatoire)
    matrix.append(ligne)
  return matrix

seed(0)
A = creer_matrice_aleatoire(3, 2)
B = creer_matrice_aleatoire(2, 3)

print(A, B)
# [[-2, 2], [1, -3], [3, 5]] [[-1, 0, 1], [3, -3, 2]]

Nous définissons une fonction nommée creer_matrice_aleatoire qui prend en argument le nombre de lignes et de colonnes désirées, ainsi que deux bornes facultatives (inférieure et supérieure) pour la génération des nombres aléatoires. Ces bornes ont par défaut les valeurs -5 et 5.

matrix = []

Nous initialisons une liste vide qui servira à contenir la matrice finale.

  for _ in range(n_lignes):
    ligne = []

Nous commençons une boucle qui s'exécute une fois pour chaque ligne à créer. Pour chaque itération, nous créons une nouvelle liste vide qui représentera une ligne de la matrice.

    for _ in range(n_colonnes):
        nombre_aleatoire = randint(low, high)
        ligne.append(nombre_aleatoire)
    matrix.append(ligne)

À l'intérieur de chaque ligne, nous bouclons sur le nombre de colonnes à remplir. Nous générons un entier aléatoire compris entre les bornes low et high, bornes incluses. Nous ajoutons ce nombre à la ligne en cours de construction. Une fois la ligne complétée, nous l'ajoutons à la matrice.

Précisons ici que matrix.append(ligne) fait partie du corps de la première boucle for, celle qui parcourt les lignes de la matrice. Autrement dit, cette ligne est exécutée à chaque itération de cette boucle, après que la liste ligne ait été remplie par la seconde boucle imbriquée (celle qui ajoute les nombres aléatoires pour chaque colonne).

return matrix

Lorsque toutes les lignes sont créées, nous retournons la matrice complète.

seed(0)

Nous fixons la graine du générateur aléatoire à 0 pour que les résultats soient reproductibles à chaque exécution du script.

A = creer_matrice_aleatoire(3, 2)

Nous appelons la fonction pour créer une matrice de 3 lignes et 2 colonnes, en utilisant la graine fixée précédemment.

B = creer_matrice_aleatoire(2, 3)

Nous appelons à nouveau la fonction pour générer une autre matrice, cette fois avec 2 lignes et 3 colonnes.

print(A, B)

Nous affichons les deux matrices générées.

# [[-2, 2], [1, -3], [3, 5]] [[-1, 0, 1], [3, -3, 2]]

Ce commentaire montre les résultats des deux matrices lorsque le générateur aléatoire est initialisé avec la graine 0.

Résumé du fonctionnement :

Nous commençons par initialiser une liste vide appelée matrix qui contiendra notre matrice finale. Ensuite, pour chaque ligne à générer, nous créons une liste vide ligne dans laquelle nous ajoutons, à l'aide d'une boucle, des entiers aléatoires compris entre les bornes low et high. Ces entiers sont produits avec randint. Une fois la ligne remplie, nous l'ajoutons à la matrice principale. Après avoir répété ce processus pour chaque ligne, nous retournons la matrice complète.

Ainsi, la fonction construit une matrice ligne par ligne, en remplissant chaque ligne colonne par colonne avec des entiers tirés aléatoirement dans l'intervalle spécifié.

3.2 La multiplication matricielle :

Pour faire une multiplication matricielle, il nous faut deux matrices, appelées ici A et B.

Pour que cette multiplication soit possible, il faut que les dimensions "du milieu" (celles qui se rencontrent dans le calcul) soient les mêmes. Soient deux matrices :

A de taille m × n : Cela signifie que A a m lignes et n colonnes.

B de taille n × p : Cela signifie que B a n lignes et p colonnes.

(m x n) x (n x p) = (m x p)

Pour pouvoir multiplier A × B, le nombre de colonnes de A (n) doit être égal au nombre de lignes de B (n).

Le résultat de la multiplication est une matrice C de taille m × p, c'est-à-dire m lignes et p colonnes.

Implémentation en code :

from random import randint, seed

def creer_matrice_aleatoire(n_lignes, n_colonnes, low=-5, high=5):
  matrix = []
  for _ in range(n_lignes):
    ligne = []
    for _ in range(n_colonnes):
        nombre_aleatoire = randint(low, high)
        ligne.append(nombre_aleatoire)
    matrix.append(ligne)
  return matrix

seed(0)
A = creer_matrice_aleatoire(3, 2)
B = creer_matrice_aleatoire(2, 3)

print(A, B)
# [[-2, 2], [1, -3], [3, 5]] [[-1, 0, 1], [3, -3, 2]]

Nous définissons une fonction nommée creer_matrice_aleatoire qui prend en argument le nombre de lignes et de colonnes désirées, ainsi que deux bornes facultatives (inférieure et supérieure) pour la génération des nombres aléatoires. Ces bornes ont par défaut les valeurs -5 et 5.

matrix = []

Nous initialisons une liste vide qui servira à contenir la matrice finale.

  for _ in range(n_lignes):
    ligne = []

Nous commençons une boucle qui s'exécute une fois pour chaque ligne à créer. Pour chaque itération, nous créons une nouvelle liste vide qui représentera une ligne de la matrice.

    for _ in range(n_colonnes):
        nombre_aleatoire = randint(low, high)
        ligne.append(nombre_aleatoire)
    matrix.append(ligne)

À l'intérieur de chaque ligne, nous bouclons sur le nombre de colonnes à remplir. Nous générons un entier aléatoire compris entre les bornes low et high, bornes incluses. Nous ajoutons ce nombre à la ligne en cours de construction. Une fois la ligne complétée, nous l'ajoutons à la matrice.

Précisons ici que matrix.append(ligne) fait partie du corps de la première boucle for, celle qui parcourt les lignes de la matrice. Autrement dit, cette ligne est exécutée à chaque itération de cette boucle, après que la liste ligne ait été remplie par la seconde boucle imbriquée (celle qui ajoute les nombres aléatoires pour chaque colonne).

return matrix

Lorsque toutes les lignes sont créées, nous retournons la matrice complète.

seed(0)

Nous fixons la graine du générateur aléatoire à 0 pour que les résultats soient reproductibles à chaque exécution du script.

A = creer_matrice_aleatoire(3, 2)

Nous appelons la fonction pour créer une matrice de 3 lignes et 2 colonnes, en utilisant la graine fixée précédemment.

B = creer_matrice_aleatoire(2, 3)

Nous appelons à nouveau la fonction pour générer une autre matrice, cette fois avec 2 lignes et 3 colonnes.

print(A, B)

Nous affichons les deux matrices générées.

# [[-2, 2], [1, -3], [3, 5]] [[-1, 0, 1], [3, -3, 2]]

Ce commentaire montre les résultats des deux matrices lorsque le générateur aléatoire est initialisé avec la graine 0.

Résumé du fonctionnement :

Nous commençons par initialiser une liste vide appelée matrix qui contiendra notre matrice finale. Ensuite, pour chaque ligne à générer, nous créons une liste vide ligne dans laquelle nous ajoutons, à l'aide d'une boucle, des entiers aléatoires compris entre les bornes low et high. Ces entiers sont produits avec randint. Une fois la ligne remplie, nous l'ajoutons à la matrice principale. Après avoir répété ce processus pour chaque ligne, nous retournons la matrice complète.

Ainsi, la fonction construit une matrice ligne par ligne, en remplissant chaque ligne colonne par colonne avec des entiers tirés aléatoirement dans l'intervalle spécifié.

from random import randint, seed

def creer_matrice_aleatoire(n_lignes, n_colonnes, low=-5, high=5):
  matrix = []
  for _ in range(n_lignes):
    ligne = []
    print(ligne)
    for _ in range(n_colonnes):
        nombre_aleatoire = randint(low, high)
        ligne.append(nombre_aleatoire)
    matrix.append(ligne)
    print(matrix)
  return matrix

seed(0)
A = creer_matrice_aleatoire(3, 2)
# []
# [[1, 1]]
# []
# [[1, 1], [-5, -1]]
# []
# [[1, 1], [-5, -1], [3, 2]]

Maintenant, si nous souhaitons vérifier si notre matrice fait bien la taille de 3 lignes et 2 colonnes nous pouvons utiliser la fonction len().

En appliquant len() à une matrice, nous obtenons le nombre de lignes. Si nous souhaitons connaître le nombre de colonnes, il nous suffit de regarder la longueur de la première ligne, c'est-à-dire len(A[0]).

A = [[1, 2], [3, 4], [5, 6]]
print(len(A), len(A[0]))
# 3 2

Cela nous confirme que notre matrice possède bien 3 lignes et 2 colonnes.

Rappelons qu'une matrice est une structure bidimensionnelle, donc son attribut ndim vaut 2 si nous utilisons un tableau NumPy. Cela signifie aussi que nous avons besoin de deux crochets pour accéder à n'importe quel élément atomique dans cette matrice : un pour la ligne, un autre pour la colonne.

La shape (forme) :

En ce qui concerne la shape (ou forme) d'une matrice, elle est conventionnellement représentée sous la forme d'un tuple (n, m) où n est le nombre de lignes et m le nombre de colonnes.

Cette information est essentielle lorsqu'on veut effectuer une multiplication matricielle, car elle nous permet de vérifier si l'opération est mathématiquement possible et nous donne également une idée de la forme du résultat attendu.

Par exemple, avec la matrice A ci-dessus, sa shape est (3, 2). Si nous souhaitons effectuer une multiplication matricielle avec une autre matrice B, il faut que B ait une shape de (2, k) pour que l'opération soit valide, et le résultat aura une shape de (3, k).

Créons une fonction qui nous permettra de déterminer la shape d'un élément :

def shape(matrix):
  return (len(matrix), len(matrix[0]))

shape(A)
# (3, 2)

On peut par la suite accéder aux éléments à l'intérieur du tuple avec leurs indices :

shape(A)[0]
# 3
shape(A)[0]
# 2

Création fonction produit scalaire :

Rappelons d'abord la formule mathématique du produit scalaire. Si nous avons deux vecteurs u et v, chacun de dimension n, le produit scalaire est défini comme la somme des produits de leurs composantes respectives, soit :

Pour traduire cela en Python, nous devons créer une fonction qui itère simultanément sur les éléments des deux vecteurs et calcule la somme des produits correspondants.

u = [1, 3, 5]
v = [2, 4, 7]

def dot_product(vec_a, vec_b):
  assert len(vec_a) == len(vec_b), f"{len(vec_b)} != {len(vec_b)}"
  return sum([a * b for a, b in zip(vec_a, vec_b)])

print(dot_product(u, v))

Nous créons la fonction dot_product qui prendra en paramètre ces deux vecteurs. Avant de faire le calcul, nous ajoutons une assertion pour nous assurer qu'ils sont bien de même longueur, car le produit scalaire n'est défini que pour des vecteurs de même dimension.

En Python, lorsque nous écrivons du code, il est parfois utile de vérifier qu'une condition est vraie à un moment donné de l'exécution. Pour cela, nous utilisons l'instruction assert.

Utilisation d'assert :

L'instruction assert permet d'interrompre le programme si une condition donnée n'est pas vérifiée. C'est un outil de debugging, c'est-à-dire de vérification pendant le développement, pour nous assurer que certaines hypothèses sont bien respectées.

Utilisons un exemple simple :

x = 5
assert x > 0
print("x est positif")

Ce code s'exécute sans problème parce que x > 0 est vrai.

x = -3
assert x > 0
print("x est positif")

Dans ce second exemple, une erreur se produit car la condition x > 0 est fausse. L'interpréteur Python lève une exception de type AssertionError.

Si nous voulons personnaliser le message d'erreur, nous pouvons ajouter une chaîne de caractères après la condition :

x = -3
assert x > 0, "x doit être un nombre positif"

Cela nous aide à comprendre plus facilement pourquoi le programme a échoué.

L'instruction assert est très utile pendant le développement, mais elle peut être désactivée si Python est exécuté en mode optimisé (avec l'option -O). Pour cette raison, nous ne devons pas l'utiliser pour gérer les erreurs qui doivent toujours être vérifiées en production.

En résumé, assert est un outil pratique pour valider nos hypothèses et attraper rapidement des erreurs logiques dans le code.

La fonction zip nous permet d'itérer en parallèle sur les deux listes, et pour chaque paire (a, b), nous multiplions et ajoutons le résultat à la somme totale.

print(dot_product(u, v))
# Résultat : 1*2 + 3*4 + 5*7 = 2 + 12 + 35 = 49
# 49

Rappel utilisation fonction zip et de plusieurs variables d'itération :

La fonction zip en Python prend plusieurs itérables (comme des listes, des tuples, etc.) et retourne un itérateur qui regroupe les éléments position par position.

Prenons un exemple simple pour visualiser :

a = [1, 2, 3]
b = [4, 5, 6]

z = zip(a, b)
print(list(z))
# [(1, 4), (2, 5), (3, 6)]

Utilisation de plusieurs variables d'itération dans une boucle for :

Lorsque nous écrivons une boucle comme for i in iterable:, nous parcourons une suite de valeurs simples. Cela signifie que Python va, à chaque itération, extraire un élément de la structure iterable et le placer dans la variable i. Cette structure peut être, par exemple, une liste d'entiers ou une liste de chaînes de caractères.

Mais si nous écrivons une boucle sous la forme for x, y in iterable:, nous exprimons une intention différente. Ici, nous attendons que chaque élément de iterable soit une structure contenant exactement deux sous-éléments, comme un tuple ou une liste de deux éléments. Python nous permet alors de "dépaqueter" directement ces éléments dans deux variables distinctes : x et y.

Prenons un exemple très concret pour illustrer cela :

points = [(1, 2), (3, 4), (5, 6)]

for x, y in points:
    print(f"x = {x}, y = {y}")

# x = 1, y = 2
# x = 3, y = 4
# x = 5, y = 6

Dans cet exemple, chaque élément de la liste points est un tuple de deux éléments. Python nous offre la possibilité de les décomposer immédiatement dans deux variables, ce qui rend le code plus lisible et plus pratique à manipuler.

Si au lieu de cela, nous avions utilisé une seule variable, nous aurions obtenu :

for point in points:
    print(point)

# (1, 2)
# (3, 4)
# (5, 6)

Nous recevons bien chaque point, mais sous forme de tuple complet. Ce n'est pas problématique, mais cela peut nous obliger à accéder ensuite aux éléments du tuple via des indices (point[0], point[1]), ce qui est moins clair.

Reprenons maintenant un autre cas fréquent en Python, l'utilisation de zip :

u = [1, 2, 3]
v = [4, 5, 6]

for a, b in zip(u, v):
    print(f"a = {a}, b = {b}")

# a = 1, b = 4
# a = 2, b = 5
# a = 3, b = 6

La fonction zip combine les éléments de u et v deux à deux, et à chaque itération, elle produit un tuple (a, b) que nous pouvons immédiatement assigner à deux variables.

Et nous ne sommes pas limités à deux éléments. Si chaque élément de notre structure contient trois sous-éléments, nous pouvons également les répartir dans trois variables :

coords = [(1, 2, 3), (4, 5, 6)]

for x, y, z in coords:
    print(x + y + z)

# 6
# 15

Ce dépaquetage automatique est très puissant. Il nous permet d'écrire un code plus lisible, plus expressif et surtout plus clair dans ses intentions. Il nous suffit de nous assurer que chaque élément contient exactement le bon nombre de sous-éléments, sinon Python nous renverra une erreur.

Fonction multiplication matricielle :

Procédons en plusieurs étapes.

Étape 1 :

On définit la fonction et on la teste.

def mat_mul(matrix_A, matrix_B):
    sA = shape(matrix_A)
    sB = shape(matrix_B)
    print(f"shape de A {sA} shape de B {sB}")
    assert sA[1] == sB[0], f"{sA[1]} != {sB[0]}"
    sM = sA[0], sB[1]

A = creer_matrice_aleatoire(3, 4)
B = creer_matrice_aleatoire(4, 2)

mat_mul(A, B)
# shape de A (3, 4) shape de B (4, 2)
# ok

mat_mul(B, A)
# shape de A (4, 2) shape de B (3, 4)
# AssertionError: 2 != 3

D'abord, nous récupérons la forme (ou dimensions) de matrix_A en appelant la fonction shape sur celle-ci, et nous stockons le résultat dans la variable sA.

De la même façon, nous récupérons la forme de matrix_B et la stockons dans la variable sB.

Nous affichons ensuite les formes des deux matrices à l'aide d'un print, ce qui nous permet de visualiser leurs dimensions au moment de l'appel.

Nous utilisons une assertion pour vérifier que le nombre de colonnes de matrix_A (c'est-à-dire sA[1]) est égal au nombre de lignes de matrix_B (c'est-à-dire sB[0]).

Cette condition est essentielle pour que la multiplication matricielle soit définie.

Si l'assertion est validée, nous calculons la forme de la matrice résultat, qui aura pour dimensions le nombre de lignes de matrix_A et le nombre de colonnes de matrix_B. Cette forme est stockée dans sM, mais n'est pas utilisée plus loin dans ce bout de code.

Ensuite, nous créons deux matrices aléatoires : la première de taille 3×4 et la deuxième de taille 4×2, et nous les stockons respectivement dans A et B.

Nous appelons la fonction mat_mul avec A et B comme arguments. Les dimensions sont compatibles pour la multiplication (3×4 et 4×2), donc l'assertion passe, et nous voyons un message indiquant les dimensions.

Puis, nous testons l'appel de la fonction avec B et A dans l'ordre inverse. Cette fois, les dimensions sont 4×2 pour matrix_A et 3×4 pour matrix_B, ce qui fait que l'assertion échoue (car 2 ≠ 3). Cela provoque une erreur AssertionError, accompagnée du message qui indique la discordance entre les dimensions.

Étape 2 :

Il est important de comprendre que dans une multiplication matricielle, les valeurs qui seront présentes dans la matrice résultante seront égales aux produits scalaires des lignes de A avec les colonnes de B.

Dans une multiplication matricielle, on sait que le nombre de colonnes de A doit être égal au nombre de lignes de B. Alors la matrice résultante AB sera de la taille du nombre de lignes de A et du nombre de colonnes de B.

Pour chaque "case" du tableau, le résultat sera le dot_product des coordonnées de chaque éléments, comme décrit plus haut.

Nous devons donc extraire les lignes de A et les colonnes de B. Pour la ligne de A nous utiliserons une simple boucle for mais pour les colonnes de B nous devons créer une fonction.

def extraire_colonne(matrice, indice_col):
  colonne = []
  for ligne in matrice:
    colonne.append(ligne[indice_col])
  return colonne

Ou en compréhension de liste :

def extraire_colonne(matrice, indice_colonne):
  return [ligne[indice_colonne] for ligne in matrice]

Maintenant, intégrons cette logique dans notre fonction mat_mul, qui réalisera la multiplication entre deux matrices. Voici notre approche complète :

def mat_mul(matrix_A, matrix_B):
    sA = shape(matrix_A)
    sB = shape(matrix_B)
    
    # Affiche les dimensions des matrices A et B
    print(f'shape de A : {sA}\nshape de B : {sB}')
    
    # Vérifie la compatibilité pour la multiplication
    n_lignes_A, n_colonnes_A = sA[0], sA[1]
    n_lignes_B, n_colonnes_B = sB[0], sB[1]
    assert n_colonnes_A == n_lignes_B, f'{sA[1]} != {sB[0]}'
    
    # Calcule la dimension de la matrice résultante
    n_lignes_M = n_lignes_A
    n_colonnes_M = n_colonnes_B
    sM = (n_lignes_M, n_colonnes_M)
    print(f'La shape de la matrice résultante sera {sM}')
    
    # Procède à la multiplication
    matrix_M = []
    for i in range(n_lignes_M):
        ligne = []
        for j in range(n_colonnes_M):
            # Récupère la ligne i de A
            ligne_i_de_A = matrix_A[i]
            # Récupère la colonne j de B
            colonne_j_de_B = extraire_colonne(matrix_B, indice_col=j)
            # Calcule le produit scalaire
            nombre = dot_product(ligne_i_de_A, colonne_j_de_B)
            # Ajoute le résultat à la ligne
            ligne.append(nombre)
        # Ajoute la ligne à la matrice résultante
        matrix_M.append(ligne)
    
    # Retourne le résultat final
    return matrix_M

Synthèse de la fonction mat_mul :

Nous définissons une fonction nommée mat_mul qui prend deux arguments : matrix_A et matrix_B, représentant les matrices à multiplier.

    sA = shape(matrix_A)
    sB = shape(matrix_B)

Nous récupérons les dimensions (ou "shapes") des deux matrices à l'aide d'une fonction shape (qu'on suppose définie ailleurs), et nous les stockons respectivement dans sA et sB.

    print(f'shape de A : {sA}\nshape de B : {sB}')

Nous affichons les dimensions des deux matrices, ce qui nous aide à vérifier visuellement leur compatibilité.

    n_lignes_A, n_colonnes_A = sA[0], sA[1]
    n_lignes_B, n_colonnes_B = sB[0], sB[1]

Nous extrayons séparément le nombre de lignes et de colonnes des deux matrices pour les utiliser plus facilement par la suite.

    assert n_colonnes_A == n_lignes_B, f'{sA[1]} != {sB[0]}'

Nous vérifions que le nombre de colonnes de la première matrice est égal au nombre de lignes de la seconde, condition nécessaire pour que la multiplication soit définie.

    n_lignes_M = n_lignes_A
    n_colonnes_M = n_colonnes_B
    sM = (n_lignes_M, n_colonnes_M)

Nous déterminons les dimensions de la matrice résultante, qui aura autant de lignes que matrix_A et autant de colonnes que matrix_B. On stocke cette dimension dans sM.

    print(f'La shape de la matrice résultante sera {sM}')

Nous affichons la dimension attendue de la matrice résultante pour confirmation.

    matrix_M = []

Nous initialisons une liste vide qui va contenir les lignes de la matrice résultante.

    for i in range(n_lignes_M):
        ligne = []

Nous parcourons les lignes de la matrice résultante. Pour chaque ligne, nous créons une liste vide ligne.

        for j in range(n_colonnes_M):

Nous parcourons ensuite les colonnes de la matrice résultante.

            ligne_i_de_A = matrix_A[i]

Nous extrayons la ligne i de matrix_A.

            colonne_j_de_B = extraire_colonne(matrix_B, indice_col=j)

Nous extrayons la colonne j de matrix_B à l'aide d'une fonction extraire_colonne (supposée définie ailleurs).

            nombre = dot_product(ligne_i_de_A, colonne_j_de_B)

Nous calculons le produit scalaire entre la ligne extraite de matrix_A et la colonne extraite de matrix_B, ce qui donne un élément de la matrice résultante.

            ligne.append(nombre)

Nous ajoutons ce nombre à la ligne en cours de construction.

        matrix_M.append(ligne)

Une fois la ligne complétée, nous l'ajoutons à la matrice résultante.

    return matrix_M

Nous retournons la matrice complète obtenue après avoir parcouru toutes les lignes et colonnes nécessaires.