Planifiez vos expériences avec un plan factoriel
Le script nocturne d’agrégation des ventes est trop lent. L’équipe a trois idées : lire les données au format Parquet plutôt qu’en CSV, activer un cache des tables de référence, et paralléliser le calcul sur 8 processus. Chaque essai prend une dizaine de minutes. Faut-il tester les trois idées une par une ? Toutes les combinaisons ? Et comment savoir laquelle compte vraiment ?
C’est l’objet des plans d’expériences (design of experiments) : obtenir le maximum d’information avec le minimum d’essais, sans passer à côté des interactions entre facteurs.
Trois façons de planifier
Section intitulée « Trois façons de planifier »Rappelons le vocabulaire du chapitre 1 : un facteur est un paramètre que l’on fait varier (le format, le cache, le parallélisme), un niveau est une valeur de ce facteur (CSV ou Parquet), la réponse est la métrique mesurée (ici, la durée du script).
| Plan | Nombre d’essais pour facteurs à niveaux | Avantage | Limite |
|---|---|---|---|
| Un facteur à la fois | très peu d’essais | ne voit pas les interactions, peut conclure à tort | |
| Factoriel complet | toute l’information | explose avec le nombre de facteurs | |
| Plan | voit toutes les interactions, analyse simple | deux niveaux seulement par facteur | |
| Plan fractionnaire | encore moins d’essais | certains effets sont confondus |
Avec 3 facteurs à 4 niveaux, le plan « un facteur à la fois » demande essais, le factoriel complet .
Pourquoi le plan « un facteur à la fois » est-il dangereux ? Partez de la configuration « CSV, sans cache, 1 processus », qui prend 120 s. Passer à 8 processus fait gagner 50 s ; passer à Parquet en fait gagner 75. Vous en déduisez que les deux ensemble feront gagner 125 s… sur un script de 120 s ! Le gain réel est bien moindre : une fois la lecture accélérée par Parquet, il reste beaucoup moins de travail à paralléliser. Deux facteurs interagissent quand l’effet de l’un dépend du niveau de l’autre, et c’est précisément ce qu’un plan « un facteur à la fois » ne peut pas voir.
Le plan : effets et interactions
Section intitulée « Le plan 222^222 : effets et interactions »On commence simple, avec deux facteurs à deux niveaux, codés et :
- : le format ( = CSV, = Parquet) ;
- : le cache ( = désactivé, = activé).
Les quatre essais donnent les durées suivantes (en secondes) :
| Essai | Durée | ||
|---|---|---|---|
| 1 | −1 | −1 | 80 |
| 2 | +1 | −1 | 30 |
| 3 | −1 | +1 | 60 |
| 4 | +1 | +1 | 20 |
On cherche un modèle de la forme :
- est la réponse moyenne ;
- est l’effet du facteur : passer de à (ou de à ) change de ;
- est l’interaction entre et .
Quatre équations, quatre inconnues : le système a une solution unique. Et grâce au codage en , la solution est très simple à calculer avec la table des signes.
La table des signes
Section intitulée « La table des signes »On écrit une colonne par terme du modèle. La colonne ne contient que des ; la colonne est le produit des colonnes et . Chaque effet s’obtient en multipliant la colonne par la colonne de signes correspondante, en sommant, puis en divisant par .
| +1 | −1 | −1 | +1 | 80 |
| +1 | +1 | −1 | −1 | 30 |
| +1 | −1 | +1 | −1 | 60 |
| +1 | +1 | +1 | +1 | 20 |
| 190 | −90 | −30 | 10 | total |
| 47,5 | −22,5 | −7,5 | 2,5 | total / 4 |
Donc s, s, s et s. Le modèle est . Vérifions l’essai 1 : . Parfait.
Quelle part de la variation chaque facteur explique-t-il ?
Section intitulée « Quelle part de la variation chaque facteur explique-t-il ? »L’effet brut ne suffit pas à comparer les facteurs : on veut savoir quelle part de la variation totale de chacun explique. La variation totale est , et l’on montre qu’elle se décompose exactement en :
Ici, , , , et . Le format explique 89 % de la variation, le cache 9,9 %, leur interaction 1,1 %. Si l’on ne peut faire qu’une chose, c’est passer à Parquet.
Le plan , en Python
Section intitulée « Le plan 232^323, en Python »Ajoutons le troisième facteur, : le parallélisme ( = 1 processus, = 8 processus). Il faut maintenant essais, et le modèle comporte 8 termes : la moyenne, 3 effets principaux, 3 interactions doubles (, , ) et une interaction triple (). Le calcul à la main devient fastidieux ; laissons Python faire :
import itertools
# Ordre standard : A varie le plus vite, puis B, puis Cessais = [(a, b, c) for c, b, a in itertools.product([-1, 1], repeat=3)]y = [120, 45, 100, 35, 70, 30, 55, 25] # durées mesurées (s)
colonnes = { "A": lambda a, b, c: a, "B": lambda a, b, c: b, "C": lambda a, b, c: c, "AB": lambda a, b, c: a * b, "AC": lambda a, b, c: a * c, "BC": lambda a, b, c: b * c, "ABC": lambda a, b, c: a * b * c,}n = len(y)effets = {nom: sum(f(*e) * yj for e, yj in zip(essais, y)) / n for nom, f in colonnes.items()}SST = sum(n * q ** 2 for q in effets.values())
print(f"q0 = {sum(y) / n}")for nom, q in sorted(effets.items(), key=lambda kv: -abs(kv[1])): print(f"{nom:<4} q = {q:7.2f} part de variation = {100 * n * q ** 2 / SST:5.1f} %")Le résultat est parlant :
| Effet | (s) | Part de variation |
|---|---|---|
| (format) | −26,25 | 66,4 % |
| (parallélisme) | −15,00 | 21,7 % |
| +8,75 | 7,4 % | |
| (cache) | −6,25 | 3,8 % |
| +2,50 | 0,6 % | |
| +1,25 | 0,2 % | |
| 0 | 0 % |
Le format domine, suivi du parallélisme. L’interaction n’est pas négligeable (7,4 %) et elle est positive alors que et sont négatifs : les deux améliorations ne s’additionnent pas complètement. Le parallélisme fait gagner 50 s en CSV sans cache (de 120 à 70 s) mais seulement 15 s en Parquet sans cache (de 45 à 30 s). Une fois la lecture accélérée, il reste moins de calcul à paralléliser.
Répéter pour estimer l’erreur : le plan
Section intitulée « Répéter pour estimer l’erreur : le plan 2kr2^k r2kr »Jusqu’ici, chaque configuration n’a été mesurée qu’une fois. Or le chapitre 7 l’a montré : une mesure est une variable aléatoire. Comment savoir si un effet de 2,5 s est réel ou s’il n’est que du bruit ? En répétant chaque essai fois : c’est le plan .
Reprenons le plan avec répétitions :
| Essai | Mesures (s) | Moyenne | ||
|---|---|---|---|---|
| 1 | −1 | −1 | 78, 82, 80 | 80 |
| 2 | +1 | −1 | 29, 31, 30 | 30 |
| 3 | −1 | +1 | 57, 61, 62 | 60 |
| 4 | +1 | +1 | 21, 19, 20 | 20 |
Les effets se calculent sur les moyennes, comme avant : , , . La nouveauté est l’erreur expérimentale : l’écart de chaque mesure à la moyenne de son essai. Sa somme des carrés vaut . La variation totale se décompose en :
Numériquement : (88,7 %), (9,9 %), (1,1 %) et (0,4 %), pour un total .
On peut aussi donner un intervalle de confiance pour chaque effet. L’écart type de l’erreur vaut s, et celui d’un effet s. Avec , chaque effet est connu à s près. L’interaction s ne contient pas 0 : elle est faible, mais réelle.
Moins d’essais : les plans fractionnaires
Section intitulée « Moins d’essais : les plans fractionnaires »Avec 7 facteurs, un plan demande 128 essais. Un plan fractionnaire n’en réalise qu’une fraction, , choisie astucieusement. Par exemple, un plan étudie trois facteurs en 4 essais : on réalise le plan en et , et on fixe le niveau de par la règle .
Le prix à payer est la confusion (aliasing) : la colonne de est identique à celle de , donc l’effet calculé pour est en réalité . On ne peut pas les séparer. Cela reste acceptable si l’on a de bonnes raisons de penser que les interactions sont faibles devant les effets principaux.
D’où une démarche en deux phases, classique :
- Criblage : beaucoup de facteurs, deux niveaux chacun, plan ou fractionnaire, pour identifier les quelques facteurs qui comptent ;
- Approfondissement : ces quelques facteurs seulement, avec plus de niveaux et des répétitions, pour trouver le bon réglage.
En résumé
Section intitulée « En résumé »- Faire varier un facteur à la fois coûte peu mais rate les interactions ; le factoriel complet voit tout mais explose.
- Le plan teste facteurs à deux niveaux codés en essais, avec le modèle
- Chaque effet se calcule par la table des signes : ; sa part de variation est .
- Le plan répète chaque essai fois, ce qui permet d’estimer l’erreur (SSE) et de donner un intervalle de confiance pour chaque effet.
- Les plans fractionnaires économisent des essais au prix d’effets confondus.
- Démarche en deux phases : criblage de nombreux facteurs, puis approfondissement des facteurs importants.
Exercices
Section intitulée « Exercices »Exercice · Calculer les effets d'un plan 2^2
facileOn mesure le débit (en requêtes/s) d’un serveur selon la taille de la mémoire ( : 8 Go ou 32 Go) et le nombre de fils d’exécution ( : 4 ou 16). Résultats : (8 Go, 4 fils) : 200 ; (32 Go, 4 fils) : 260 ; (8 Go, 16 fils) : 300 ; (32 Go, 16 fils) : 440.
Calculez , , , et la part de variation expliquée par chacun.
Voir la solution
Avec les signes (, ) = (−,−), (+,−), (−,+), (+,+) :
- ;
- ;
- ;
- .
, , , donc . Les fils expliquent 62,8 % de la variation, la mémoire 32,1 %, l’interaction 5,1 %. L’interaction positive indique que la mémoire supplémentaire est plus utile quand il y a beaucoup de fils (+140 req/s avec 16 fils, contre +60 avec 4).
En code
import numpy as np
xA = np.array([-1, 1, -1, 1]) # mémoire : 8 Go (-1) ou 32 Go (+1)xB = np.array([-1, -1, 1, 1]) # fils : 4 (-1) ou 16 (+1)y = np.array([200, 260, 300, 440]) # débit (req/s)
signes = {"I": np.ones(4), "A": xA, "B": xB, "AB": xA * xB} # table des signesq = {nom: float(col @ y) / 4 for nom, col in signes.items()}print(q) # {'I': 300.0, 'A': 50.0, 'B': 70.0, 'AB': 20.0}
SS = {nom: 4 * q[nom] ** 2 for nom in ("A", "B", "AB")}SST = sum(SS.values())print(SST, {nom: round(100 * v / SST, 1) for nom, v in SS.items()})# 31200.0 {'A': 32.1, 'B': 62.8, 'AB': 5.1}xA <- c(-1, 1, -1, 1) # mémoire : 8 Go (-1) ou 32 Go (+1)xB <- c(-1, -1, 1, 1) # fils : 4 (-1) ou 16 (+1)y <- c(200, 260, 300, 440) # débit (req/s)
# Avec un codage en ±1, les coefficients de la régression sont exactement les effetsq <- coef(lm(y ~ xA * xB))q # 300 50 70 20
SS <- 4 * q[-1]^2 # SS_A, SS_B, SS_ABround(c(SST = sum(SS), 100 * SS / sum(SS)), 1) # 31200 32.1 62.8 5.1Exercice · Le piège du un facteur à la fois
moyenReprenez les données du plan du chapitre (format , cache , parallélisme ). Un collègue part de la configuration (CSV, sans cache, 1 processus) et teste chaque facteur seul.
- Quelles trois configurations teste-t-il, et quelles durées observe-t-il ?
- Il décide d’activer les trois améliorations en additionnant les gains observés. Quelle durée prévoit-il ? Quelle est la durée réelle ?
Voir la solution
- Il teste (Parquet, sans cache, 1 processus) : 45 s ; (CSV, cache, 1 processus) : 100 s ; (CSV, sans cache, 8 processus) : 70 s. La configuration de départ fait 120 s.
- Gains observés : 75 s pour le format, 20 s pour le cache, 50 s pour le parallélisme, soit 145 s au total. Il prévoit s, ce qui est absurde ! La durée réelle de (Parquet, cache, 8 processus) est 25 s. Les gains ne s’additionnent pas parce que les facteurs interagissent (surtout et ) : une fois la lecture accélérée, il reste bien moins de travail à paralléliser ou à mettre en cache. Seul un plan factoriel permet de le voir.
En code
import itertools
# Plan 2^3 du chapitre, ordre standard (A varie le plus vite, puis B, puis C)essais = [(a, b, c) for c, b, a in itertools.product([-1, 1], repeat=3)]duree = dict(zip(essais, [120, 45, 100, 35, 70, 30, 55, 25]))
depart = duree[(-1, -1, -1)] # CSV, sans cache, 1 processusgains = {"format": depart - duree[(1, -1, -1)], # un seul facteur change à la fois "cache": depart - duree[(-1, 1, -1)], "parallélisme": depart - duree[(-1, -1, 1)]}print(gains) # format 75, cache 20, parallélisme 50print(depart - sum(gains.values()), duree[(1, 1, 1)]) # -25 (prévision absurde) contre 25 réels# Plan 2^3 du chapitre, ordre standard (A varie le plus vite, puis B, puis C)plan <- expand.grid(A = c(-1, 1), B = c(-1, 1), C = c(-1, 1))plan$duree <- c(120, 45, 100, 35, 70, 30, 55, 25)duree <- function(a, b, c) plan$duree[plan$A == a & plan$B == b & plan$C == c]
depart <- duree(-1, -1, -1) # CSV, sans cache, 1 processusgains <- c(format = depart - duree(1, -1, -1), # un seul facteur change à la fois cache = depart - duree(-1, 1, -1), parallelisme = depart - duree(-1, -1, 1))gains # 75 20 50c(prevision = depart - sum(gains), reel = duree(1, 1, 1)) # -25 25Exercice · Un plan fractionnaire
difficileOn veut étudier trois facteurs avec seulement 4 essais, grâce au plan défini par .
- Écrivez les quatre essais (niveaux de , , ).
- Avec les durées du plan du chapitre, quelles durées obtient-on pour ces quatre essais ? Calculez l’effet estimé de .
- Comparez à et du plan complet. Qu’observez-vous ?
Voir la solution
- Les essais sont (−,−,+), (+,−,−), (−,+,−), (+,+,+) : pour chacun, est le produit .
- Dans le plan complet, ces configurations ont donné 70, 45, 100 et 25 s. L’effet estimé de vaut s.
- Dans le plan complet, et , et l’on retrouve bien . C’est la confusion : dans le plan fractionnaire, la colonne de est identique à celle de , et l’on n’estime que leur somme. Ici l’interaction est petite, donc l’estimation reste proche de la vérité ; si elle avait été forte, on aurait pu tirer une conclusion fausse sur .
En code
import itertoolsimport numpy as np
# Plan complet 2^3 du chapitre (A varie le plus vite)plan = np.array([(a, b, c) for c, b, a in itertools.product([-1, 1], repeat=3)])y = np.array([120, 45, 100, 35, 70, 30, 55, 25])A, B, C = plan.T
# Plan fractionnaire 2^(3-1) : on ne garde que les essais où x_C = x_A * x_Bgarde = C == A * Bprint(plan[garde].tolist(), y[garde].tolist())# [[1, -1, -1], [-1, 1, -1], [-1, -1, 1], [1, 1, 1]] [45, 100, 70, 25]q_C_frac = C[garde] @ y[garde] / 4q_C, q_AB = C @ y / 8, (A * B) @ y / 8 # effets du plan completprint(q_C_frac, q_C, q_AB, q_C + q_AB) # -12.5 -15.0 2.5 -12.5 : confusion# Plan complet 2^3 du chapitre (A varie le plus vite)plan <- expand.grid(A = c(-1, 1), B = c(-1, 1), C = c(-1, 1))y <- c(120, 45, 100, 35, 70, 30, 55, 25)
# Plan fractionnaire 2^(3-1) : on ne garde que les essais où x_C = x_A * x_Bgarde <- with(plan, C == A * B)cbind(plan[garde, ], y = y[garde]) # durées 45, 100, 70, 25q_C_frac <- sum(plan$C[garde] * y[garde]) / 4q_C <- sum(plan$C * y) / 8 # effets du plan completq_AB <- sum(plan$A * plan$B * y) / 8c(q_C_frac, q_C, q_AB, q_C + q_AB) # -12.5 -15 2.5 -12.5 : confusion