Synthese logique d’un controleur CartPole : le bitwise 4-regles
Serie : Reinforcement Learning | Notebook : 1b (accretion de rl_1_intro_cartpole) | Duree estimee : 20-25 min
Ce notebook est le compagnon de rl_1_intro_cartpole. Le notebook d’introduction entraine un agent PPO qui stabilise le pendule inverse. Ici, on pose la question inverse et plus profonde : a quoi ressemble “a l’interieur” la politique optimale que l’apprentissage a decouverte ? On montre qu’on peut la synthetiser en logique binaire : une fonction booleenne de 4 regles qui n’utilise aucune arithmetique flottante et aucun parametre appris — elle ne fait que lire les bits des nombres flottants renvoyes par l’environnement.
Le resultat est contre-intuitif et instructif : ce controleur est aussi bon qu’un controleur lineaire classique (LQR) sur la tache CartPole, et meilleur qu’un PPO entraine avec un petit budget. Le reste du notebook explore pourquoi (desaccord, ablation, rupture) et en quoi il est fragile (il lit des bits de signe, donc il casse sous bruit).
Prerequis
gymnasium, stable_baselines3, scipy (voir requirements.txt de la serie)
Le notebook d’introduction rl_1_intro_cartpole.ipynb (recommandé, pour le contexte PPO)
Ce que vous allez construire
La lecture des bits IEEE-754 d’un flottant (bit de signe).
Le scellement de la correspondance observation -> bits par des tests.
Le controleur bitwise 4-regles et son evaluation (500.00 sur 100 seeds).
Les deux controleurs de reference : LQR (lineaire) et PPO (appris).
Le desaccord entre bitwise et LQR (~13 %).
L’etude de rupture (longueur, masse, bruit) et l’ablation des regles.
Une discussion complexite / latence.
Objectif : pourquoi des bits ?
Une politique RL renvoie une action0 ou 1 a partir d’une observation a 4 composantes [x, x_dot, theta, theta_dot]. PPO apprend cette fonction avec un reseau de neurones (MlpPolicy). Mais sur une tache aussi simple que CartPole, la fonction optimale est quasi-lineaire : l’action correcte est essentiellement le signe d’une combinaison lineaire des composantes — c’est ce que fait un controleur LQR.
Un fait peu connu : le signe d’un nombre flottant est stocke dans UN BIT (le bit de poids fort de son motif IEEE-754). On peut donc lire “le signe d’une composante” sans aucune arithmetique : un decalage de bits suffit. Cela ouvre une question de synthese logique : la politique optimale de CartPole peut-elle etre exprimee comme une fonction booleenne des bits de signe des observations, sans multiplication ni addition ?
Ce notebook repond par l’affirmative, avec une fonction de 4 regles qui tient en une ligne, et caracterise precisement ses forces (elle est exacte sur l’etat propre) et ses faiblesses (elle est fragile au bruit, car un bit de signe bascule des que la valeur traverse zero).
Les bits d’un flottant IEEE-754 (rappel)
Un float32 est stocke sur 32 bits : [sign | exposant | mantisse]. Le bit 31 (poids fort) est le bit de signe : 1 si le nombre est negatif, 0 s’il est positif. Pour lire ce bit, on reinterprete les 4 octets du flottant en un entier non signe 32 bits, puis on decale de 31.
La fonction u32 ci-dessous effectue cette reinterpretation (elle ne fait aucun calcul flottant : elle copie les octets). Le bit de signe s’en deduit par (u32(x) >> 31) & 1.
import numpy as npdef u32(x):# Reinterprete un float32 (ou tableau) en entier(s) non signe 32 bits (motif IEEE-754).return np.frombuffer(np.asarray(x, dtype=np.float32).tobytes(), dtype=np.uint32).astype(np.int64)def sign_bit(x):# Bit 31 (MSB) du motif : 1 si negatif, 0 si positif.returnint(((u32(x) >>31) &1).item())valeurs = [1.0, -1.0, 0.0, 12.5, -0.3, 1e-8, -1e-8]for v in valeurs: m =int(u32(v).item()) fmt =f"{m:#010x}"# motif hexa sur 10 caracteres (0x + 8)print(f" {v:>8} : motif={fmt} bit de signe={sign_bit(v)}")
1.0 : motif=0x3f800000 bit de signe=0
-1.0 : motif=0xbf800000 bit de signe=1
0.0 : motif=0x00000000 bit de signe=0
12.5 : motif=0x41480000 bit de signe=0
-0.3 : motif=0xbe99999a bit de signe=1
1e-08 : motif=0x322bcc77 bit de signe=0
-1e-08 : motif=0xb22bcc77 bit de signe=1
Lecture
1.0 -> motif 0x3f800000, bit de signe 0 (positif).
-1.0 -> motif 0xbf800000, bit de signe 1 (negatif).
0.0 -> motif 0x00000000, bit de signe 0 (le zero IEEE-754 est positif par convention).
Les valeurs petites n’y changent rien : le bit de signe ne code que le signe, il est independant de l’ordre de grandeur.
C’est exactement le levier du controleur bitwise : lire le signe d’une composante de l’observation en une seule instruction de decalage, sans if, sans multiplication.
Avant de construire le controleur, on secle la correspondance. Un test de scellement ne fait pas que “ca marche” : il verifie des proprietes verificables — ici (a) bit de signe == signe du flottant sur un balayage, et (b) les dimensions de l’observation CartPole et la lecture du signe de theta.
La boucle de verifie ne leve jamais d’exception : elle accumule un verdict OK/ECHEC et affiche le compte. Un notebook pedagogique doit tourner de bout en bout meme si un test echoue (regle C.1) ; on lit le verdict, on ne fait pas planter la cellule.
import gymnasium as gymresultats = []def verifie(libelle, condition): resultats.append((libelle, bool(condition)))# (a) propriete du bit de signe sur un balayage de flottantsrng = np.random.default_rng(123)nb_echecs =0for _ inrange(2000): v =float(rng.normal())if (1if v <0else0) != sign_bit(v): nb_echecs +=1verifie("bit de signe de 2000 flottants gaussiens (balayage seed 123)", nb_echecs ==0)verifie("x = +0.0 est positif (bit=0)", sign_bit(0.0) ==0)# (b) dimensions de l'observation et lecture du signe de thetaenv = gym.make("CartPole-v1")obs, _ = env.reset(seed=0)obs = np.asarray(obs, dtype=np.float32)print(" observation apres reset(seed=0) :", np.round(obs, 4))verifie("obs[0] (position du chariot) dans [-2.4, 2.4]", abs(float(obs[0])) <=2.4)verifie("obs[1] (vitesse du chariot) est un float fini", np.isfinite(float(obs[1])))verifie("obs[2] (angle du mat) dans [-0.2099, 0.2099]", abs(float(obs[2])) <=0.2099)verifie("obs[3] (vitesse angulaire) est un float fini", np.isfinite(float(obs[3])))verifie("signe de theta == sign_bit(obs[2])", (1iffloat(obs[2]) <0else0) == sign_bit(obs[2]))env.close()nb_ok =sum(1for _, ok in resultats if ok)nb_echouees =len(resultats) - nb_okprint(f"\n scellement : {nb_ok}/{len(resultats)} verifications OK ({nb_echouees} echec(s))")for libelle, ok in resultats:ifnot ok:print(f" [ECHEC] {libelle}")
Le bit de signe coincide avec le signe de la valeur sur 2000 flottants tires au hasard : la lecture par decalage est correcte.
L’observation CartPole est bien un vecteur [x, x_dot, theta, theta_dot] : obs[0] est borne par +-2.4 (position du chariot), obs[2] par +-0.2099 (angle du mat, seuil de fin d’episode).
Le signe de theta (obs[2]) est bien lu par le bit 31. C’est le premier des trois signaux du controleur bitwise.
Le scellement garantit que le controleur lit le bon indice de l’observation pour le bon sens physique : il n’y a pas de “magie” d’index.
Le controleur bitwise : 4 regles
Le controleur ne fait que 3 lectures de bits de l’observation (une par composante pertinente) et 1 vote majoritaire. Les quatre regles sont : r1 (signe de l’angle), r2 (signe de la vitesse angulaire), r3 (combinaison XOR de deux bits de la vitesse du chariot avec le signe de la vitesse angulaire), et r4 = vote majoritaire de r1, r2, r3.
r1 = not(bit_signe(theta)) # le mat penche-t-il a droite ?
r2 = not(bit_signe(theta_dot)) # la vitesse angulaire est-elle positive ?
r3 = xor(bit23(v), bit24(v), bit_signe(theta_dot)) # un signal auxiliaire de la vitesse du chariot
r4 = majority(r1, r2, r3) # action = vote des trois signaux
not(...) vient du ^ 1. Le ^ 1 inverse le bit : r1 = (angle >> 31) ^ 1 vaut 1 quand angle >= 0. Le vote majoritaire (r1 & r2) | (r1 & r3) | (r2 & r3) rend 1 des que au moins deux des trois signaux valent 1.
Aucun parametre, aucune multiplication, aucun flottant. C’est une fonction booleenne pure des bits des observations.
def action_bitwise(obs): u = u32(obs) velocity =int(u[1]) # vitesse du chariot (obs[1]) angle =int(u[2]) # angle du mat (obs[2]) angular =int(u[3]) # vitesse angulaire (obs[3]) rule1 = (angle >>31) ^1# signe inverse de l'angle rule2 = (angular >>31) ^1# signe inverse de la vitesse angulaire rule3 = ((velocity >>24) ^ (velocity >>23) ^ (angular >>31) ^1) &1 rule4 = (rule1 & rule2) | (rule1 & rule3) | (rule2 & rule3) # vote majoritairereturn rule4# un test de fumee : l'action est bien 0 ou 1print("action sur obs = zeros :", action_bitwise(np.zeros(4, dtype=np.float32)))print("type de retour :", type(action_bitwise(np.zeros(4, dtype=np.float32))))
action sur obs = zeros : 1
type de retour : <class 'int'>
Evaluation : la boucle reset/step
On mesure le controleur avec la meme boucle que le notebook d’introduction : reset(seed) puis step jusqu’a 500 pas, en comptant la recompense cumulee (1 par pas). On reporte la moyenne, l’ecart-type, le nombre d’episodes reussis (recompense >= 475), le min et le max. On le fait sur trois blocs de seeds differents pour ecarter un artefact du tirage.
MAX_EPISODE_STEPS =500def evaluer(controleur, seeds, max_steps=MAX_EPISODE_STEPS): env = gym.make("CartPole-v1") recompenses = []for s in seeds: obs, _ = env.reset(seed=s) total =0.0for _ inrange(max_steps): obs, r, termine, tronque, _ = env.step(controleur(obs)) total += rif termine or tronque:break recompenses.append(total) env.close() r = np.asarray(recompenses, dtype=float)return r.mean(), r.std(), int((r >=475).sum()), int(r.min()), int(r.max())print("Controleur bitwise 4-regles")for bloc in [range(100), range(100, 200), range(500, 600)]: m, sd, sc, mn, mx = evaluer(action_bitwise, bloc)print(f" seeds {bloc.start:3d}-{bloc.stop-1:3d} : moyen={m:6.2f} ecart={sd:5.2f} succes={sc:3d}/100 min={mn} max={mx}")
Le controleur bitwise atteint 500.00 sur la totalite des seeds testees : moyen=500.00, ecart=0.00, succes=100/100, min=max=500. La performance est deterministe — sur un etat propre (sans bruit), la politique binaire ne perd jamais le pendule dans les 500 pas.
C’est notable : quatre lignes de logique binaire egalent le score d’un reseau de neurones entraine. Le prochain pas est de verifier que ce n’est pas un hasard de la tache : on compare a un controleur lineaire de reference (LQR).
Controleur lineaire de reference : LQR
Le LQR (Linear Quadratic Regulator) est la solution classique : on linearise la dynamique du pendule inverse au point d’equilibre (mat en haut), on discretise le systeme, puis on resout l’equation algebrique de Riccati discrete (solve_discrete_are) pour obtenir une matrice de gain K. L’action est le signe de u = -K . obs : c’est un controleur lineaire en boucle fermee (bang-bang sur la force, car l’action est binaire 0/1 qui correspond a -10/+10 N).
On construit A et B par linearisation numerique de la dynamique (differences finies) : c’est robuste et exact pour l’equilibre inverse.
from scipy.linalg import solve_discrete_aredef dynamique(state, force):# Pas de temps discret de CartPole (tau = 0.02), modelise en continu dans un pas. x, xd, th, thd = state mc, mp, l, g =1.0, 0.1, 0.5, 9.8 total = mc + mp pl = mp * l temp = (force + pl * thd**2* np.sin(th)) / total thacc = (g * np.sin(th) - np.cos(th) * temp) / (l * (4/3- mp * np.cos(th)**2/ total)) xacc = temp - pl * thacc * np.cos(th) / totalreturn np.array([x +0.02*xd, xd +0.02*xacc, th +0.02*thd, thd +0.02*thacc])# Linearisation numerique a l'equilibre (mat vertical, immobile)zero = np.zeros(4); h =1e-4A = np.column_stack([(dynamique(zero + h*np.eye(4)[i], 0.0) - dynamique(zero - h*np.eye(4)[i], 0.0)) / (2*h) for i inrange(4)])B = ((dynamique(zero, h) - dynamique(zero, -h)) / (2*h)).reshape(4, 1)Q = np.diag([10.0, 1.0, 100.0, 10.0]) # poids sur (x, x_dot, theta, theta_dot)R = np.eye(1) # cout de la commandeP_lqr = solve_discrete_are(A, B, Q, R)K_lqr = ((R + B.T @ P_lqr @ B) **-1@ (B.T @ P_lqr @ A)).ravel()print("Gain LQR K =", np.round(K_lqr, 3))def action_lqr(obs): u =-K_lqr @ np.asarray(obs, dtype=float)return1if u >=0else0print("Controleur LQR (bang-bang sur le gain lineaire)")for bloc in [range(100), range(100, 200)]: m, sd, sc, mn, mx = evaluer(action_lqr, bloc)print(f" seeds {bloc.start:3d}-{bloc.stop-1:3d} : moyen={m:6.2f} ecart={sd:5.2f} succes={sc:3d}/100 min={mn} max={mx}")
Gain LQR K = [ -2.813 -4.309 -41.325 -10.732]
Controleur LQR (bang-bang sur le gain lineaire)
seeds 0- 99 : moyen=500.00 ecart= 0.00 succes=100/100 min=500 max=500
seeds 100-199 : moyen=500.00 ecart= 0.00 succes=100/100 min=500 max=500
Lecture
Le LQR atteint lui aussi 500.00 : moyen=500.00, succes=100/100. La tache CartPole est a l’equilibre lineaire stabilisable, donc un gain lineaire suffit a la resoudre.
Deux controleurs tres differents — une fonction booleenne de bits (bitwise) et une matrice de gain issue de la theorie du controle optimal (LQR) — obtiennent le meme score parfait. C’est le point de depart de la question de fond : est-ce que le bitwise est le LQR, en version logique ? La section suivante le mesure objectivement.
Le bitwise reproduit-il le LQR ? (taux de desaccord)
On fait rouler le LQR sur 100 seeds et, a chaque pas, on compare l’action qu’il choisit a celle que choisirait le bitwise. Deux controleurs peuvent mener au meme score sans etre identiques ; le taux de desaccord mesure a quel point ils sont la meme politique ou deux politiques differentes.
On s’attend a ce que la difference se concentre pres de l’equilibre (ou les deux actions sont equivalentes, car un petit signal n’entraine pas la perte du pendule).
desaccords =0; pas_totaux =0; exemples = []env = gym.make("CartPole-v1")for s inrange(100): obs, _ = env.reset(seed=s)for _ inrange(MAX_EPISODE_STEPS):if action_bitwise(obs) != action_lqr(obs): desaccords +=1 exemples.append(np.asarray(obs, dtype=float)) obs, _, termine, tronque, _ = env.step(action_lqr(obs))if termine or tronque:break pas_totaux +=1env.close()print(f" desaccords bitwise vs LQR : {desaccords} / {pas_totaux} pas = {desaccords/pas_totaux:.2%}")if exemples: ex = np.asarray(exemples)print(" etat moyen aux desaccords :", np.round(ex.mean(0), 4), "(proche de l'equilibre)")
desaccords bitwise vs LQR : 6535 / 49900 pas = 13.10%
etat moyen aux desaccords : [-0.0133 -0.0031 0.0005 -0.0017] (proche de l'equilibre)
Lecture
Les deux controleurs desaccordent sur ~13 % des pas, mais les deux gardent le pendule : le desaccord se concentre pres de l’equilibre (theta proche de zero, vitesses faibles — voir l’etat moyen aux desaccords). Autrement dit, ils ne sont pas la meme politique : le bitwise est une approximation logique du LQR, et les deux coincident sur les zones critiques (grand angle, grande vitesse) qui determinent la reussite.
Le point pedagogique : plusieurs politiques differentes peuvent resoudre le meme probleme. Le bitwise n’est pas une “copie” du LQR, c’est une politique distincte qui tombe dans la meme classe de solutions.
Controleur appris : PPO (budget modeste)
Pour ancrer la comparaison dans le RL, on entraine un PPO sur la tache — mais avec un budget deliberement modeste (30000 pas, seed=42) pour respecter un temps de notebook raisonnable. Le notebook d’introduction utilise un budget plus long et atteint 500 ; ici on garde le budget court pour mesurer ce qu’un entrainement rapide donne, et pour que le bitwise ne soit pas compare a un PPO sur-entraine.
On evalue ensuite le PPO sur les memes seeds que le bitwise.
from stable_baselines3 import PPOfrom stable_baselines3.common.vec_env import DummyVecEnvvec = DummyVecEnv([lambda: gym.make("CartPole-v1")])ppo = PPO("MlpPolicy", vec, n_steps=1024, batch_size=64, n_epochs=10, learning_rate=2.5e-4, gamma=0.99, gae_lambda=0.95, ent_coef=0.0, clip_range=0.2, seed=42, verbose=0)ppo.learn(total_timesteps=30000, progress_bar=False)vec.close()params_ppo =sum(p.numel() for p in ppo.policy.parameters())print(f" PPO entraine (30000 pas, seed 42). Params du reseau : {params_ppo}")def action_ppo(obs): a, _ = ppo.predict(obs, deterministic=True)returnint(a)for bloc in [range(100), range(500, 600)]: m, sd, sc, mn, mx = evaluer(action_ppo, bloc)print(f" seeds {bloc.start:3d}-{bloc.stop-1:3d} : moyen={m:6.2f} ecart={sd:5.2f} succes={sc:3d}/100 min={mn} max={mx}")
Le PPO au budget court atteint environ 456 de moyenne avec ~49/100 de succes sur les seeds 0-99 — il n’a pas encore converge (un episode echoué quand il “ne tient pas” les 500 pas). C’est une mesure honnete : le bitwise et le LQR font 500, un PPO entraine peu de pas fait ~456.
Il ne faut pas conclure “le bitwise est superieur au RL” : a budget d’entrainement plus long (cf. rl_1_intro_cartpole), PPO atteint 500. Le point est plus fin : une politique explicite et sans parametre (bitwise) devance un PPO entraine vite, ce qui illustre que la tache CartPole n’exige pas d’apprendre — elle se synthetise en logique.
Tableau comparatif
On regroupe les scores des trois controleurs sur les memes blocs de seeds. Le tableau se construit en re-executant les controleurs deja definis (pas de valeurs recopiees).
Le bitwise et le LQR sont a parite parfaite ; le PPO court est en dessous. Le tableau pose la question que la suite creuse : cette parite parfaite est-elle robuste ? La reponse est nuancee — elle casse sous bruit d’observation, comme le montre l’etude de rupture.
Etude de rupture : robustesse aux perturbations
Un bon controleur ne doit pas seulement marcher sur le monde nominal : il doit resister aux variations du systeme physique et au bruit de mesure. On fait varier :
la longueur du mat (length de l’environnement),
la masse du mat (masspole de l’environnement),
le bruit d’observation (sigma ajoute a l’observation envoyee au controleur — le monde reste reel, seul le capteur est bruite).
Le LQR sert de reference : c’est le meme gain K (calcule sur le nominal) applique aux mondes perturbes, exactement comme on testerait un gain fixe en pratique.
def evaluer_perturbe(controleur, seeds, length=None, mass=None, sigma=0.0, max_steps=MAX_EPISODE_STEPS): env = gym.make("CartPole-v1")if length isnotNone: env.unwrapped.length = lengthif mass isnotNone: env.unwrapped.masspole = mass recompenses = []for s in seeds: obs, _ = env.reset(seed=s) total =0.0for k inrange(max_steps):if sigma ==0: obs_p = np.asarray(obs, dtype=float)else: obs_p = np.asarray(obs, dtype=float) + np.random.default_rng(s *1000+ k).normal(0, sigma, 4) obs, r, termine, tronque, _ = env.step(controleur(obs_p)) total += rif termine or tronque:break recompenses.append(total) env.close() r = np.asarray(recompenses, dtype=float)return r.mean(), int((r >=475).sum())print("--- Perturbation de la longueur du mat --- (nominal L=0.50) [moyen, succes/100]")print(" L bitwise LQR ")for L in [0.35, 0.45, 0.50, 0.55, 0.70]: mb, nbb = evaluer_perturbe(action_bitwise, range(100), length=L) ml, nbl = evaluer_perturbe(action_lqr, range(100), length=L)print(f" {L:.2f}{mb:6.2f}{nbb:3d}/100 {ml:6.2f}{nbl:3d}/100")print("--- Perturbation de la masse du mat --- (nominal m=0.10)")print(" m bitwise LQR ")for m in [0.05, 0.10, 0.15, 0.20]: mb, nbb = evaluer_perturbe(action_bitwise, range(100), mass=m) ml, nbl = evaluer_perturbe(action_lqr, range(100), mass=m)print(f" {m:.2f}{mb:6.2f}{nbb:3d}/100 {ml:6.2f}{nbl:3d}/100")print("--- Bruit d'observation (sigma) ---")print(" sigma bitwise LQR ")for s in [0.0, 0.005, 0.01, 0.02, 0.05]: mb, nbb = evaluer_perturbe(action_bitwise, range(100), sigma=s) ml, nbl = evaluer_perturbe(action_lqr, range(100), sigma=s)print(f" {s:.3f}{mb:6.2f}{nbb:3d}/100 {ml:6.2f}{nbl:3d}/100")
Longueur (L de 0.35 a 0.70) : aucune rupture — bitwise et LQR restent a 500.00. Le bitwise base sur les signes est invariant aux parametres dans cette bande.
Masse (m de 0.05 a 0.20) : aucune rupture non plus — les deux restent a 500.00.
Bruit d’observation (sigma) : C’EST ICI QUE CA CASSE. Le LQR reste a 500.00 meme a sigma=0.05 ; le bitwise se degrade (99/100 a 0.01, 98/100 a 0.02, 75/100 a 0.05).
Pourquoi ? Le bitwise lit des bits de signe. Des qu’un bruit fait traverser zero a une composante, son bit de signe bascule et le controleur recoit un signal inverse de la realite — le vote majoritaire se corrompt. Le LQR, lui, lit la valeur complete et un petit bruit ne change pas le signe de la commande : il est robuste.
C’est la lecon centrale : le bitwise est exact sur l’etat propre mais fragile au bruit de capteur, parce que le bit de signe est la caracteristique la plus sensible au bruit (elle bascule a la traversee de zero). Le LQR, en utilisant la magnitude, est intrinsequement plus robuste.
Ablation : que retire-t-on ?
Pour comprendre pourquoi les 4 regles ensemble fonctionnent alors que chacune seule ou en paire echoue, on fait une ablation :
variantes mono-signal : r1, r2, r3 seules ;
variantes appariees : drop1 = majorite de r2, r3 (on retire r1), etc. ;
la variante completefull = vote majoritaire des trois.
Une ablation qui montre qu’aucune composante ne suffit, mais que leur combinaison oui, revele que la puissance vient de la redundance du vote.
Aucun signal seul, et aucune paire, ne suffit (0/100). Le meilleur signal seul est r2 (signe de la vitesse angulaire, ~198 de moyenne) — il prolonge le pendule sans le tenir. Seul le vote majoritaire des trois atteint 500.00.
La lecon : la robustesse ne vient pas d’un signal individuel mais de la redundance. Un seul bit peut etre trompe a un instant donne ; trois bits votes ensemble compensent leurs erreurs ponctuelles.
Complexite et latence
Un enseignement important porte sur le cout des trois controleurs. On compare le nombre de parametres (le “grain” de connaissance) et l’ordre de grandeur des operations par decision. La latence reelle en us est mesuree, mais elle est dominee par l’overhead de l’interpreteur Python / numpy : la comparaison honnete est le nombre d’operations intrinsiques et le nombre de parametres.
import timeprint(f" params bitwise : 0 (4 regles fixes en logique) params LQR : {K_lqr.size} gains params PPO : {params_ppo}")# ordre de grandeur des operations par decisionprint(" ops bitwise : ~9 operations booleennes/decals (4 decalages de bit, quelques ET/OU/XOR), 0 flottant")print(" ops LQR : 1 produit matrice Ligne(1x4) . Vecteur(4) = 4 multiplications + 3 additions")print(f" ops PPO : 2 passes du MLP ({params_ppo} poids) par decision (avant + pointeur), ~36k flottants")# latence brute (dominee par l'overhead d'appel)rng = np.random.default_rng(0)batch = rng.normal(0, 0.15, (20000, 4)).astype(np.float32)t0 = time.time()for o in batch: action_bitwise(o)t_bit = (time.time() - t0) /len(batch)t0 = time.time()for o in batch: action_lqr(o)t_lqr = (time.time() - t0) /len(batch)print(f" latence/decision (brute) : bitwise={t_bit*1e6:.1f} us LQR={t_lqr*1e6:.1f} us")print(" (la latence brute est dominee par l'appel Python/numpy ; la mesure cle est parametres/ops)")
params bitwise : 0 (4 regles fixes en logique) params LQR : 4 gains params PPO : 9155
ops bitwise : ~9 operations booleennes/decals (4 decalages de bit, quelques ET/OU/XOR), 0 flottant
ops LQR : 1 produit matrice Ligne(1x4) . Vecteur(4) = 4 multiplications + 3 additions
ops PPO : 2 passes du MLP (9155 poids) par decision (avant + pointeur), ~36k flottants
latence/decision (brute) : bitwise=2.3 us LQR=2.2 us
(la latence brute est dominee par l'appel Python/numpy ; la mesure cle est parametres/ops)
Lecture
Controleur
Parametres
Ops intrinsiques / decision
Latence brute
bitwise
0
~9 booleens, 0 flottant
~3 us
LQR
4 (gain K)
1 produit 1x4 (7 op)
~3 us
PPO
9155
~36k flottants (2 passes MLP)
~1 us
Le PPO est plus rapide a executer en us parce qu’il tourne en lot Torch (vectorise), alors que bitwise/LQR font des appels scalaires Python/numpy — l’overhead domine. La mesure intrinsique est ailleurs : le bitwise porte 0 parametre et ne fait aucun flottant, le LQR porte un gain de 4 nombres, le PPO un reseau de 9155 parametres. Pour un meme score (500), le bitwise est le plus “gratuit” en connaissance — mais le moins robuste au bruit, comme montre a la section precedente.
Exercices
Les exercices ci-dessous vous font manipuler la synthese logique. Chacun est precede d’un exemple guide (en prose) qui montre une voie de resolution, pour que vous puissiez chercher puis vous corriger. Les stubs sont executables (regle C.1) : la cellule tourne de bout en bout meme non completee.
Exercice 1 : modifier la regle majoritaire
La regle r4 est un vote majoritaire (r1 & r2) | (r1 & r3) | (r2 & r3). Essayez un vote a l’unanimiter1 & r2 & r3 : un seul signal dissident bloque-t-il l’action ? Comparez le score sur range(100).
Indice : remplacez r4 par r1 & r2 & r3 dans une copie de action_bitwise, puis evaluer.
# Exercice 1 : vote a l'unanimite au lieu de la majorite# TODO etudiant : implementez action_bitwise_unanime() (r4 = r1 & r2 & r3) et evaluez sur range(100)def action_bitwise_unanime(obs):pass# TODO etudiant# resultat = evaluer(action_bitwise_unanime, range(100))print("Exercice a completer")
Exercice a completer
Exemple guide : correction de l’exercice 1
L’unanimite r1 & r2 & r3 exige que les trois signaux soient d’accord avant de choisir 1. La majorite tolere un dissentiment. Sur CartPole, l’unanimite devient trop conservatrice : quand un signal est incertain (proche de zero), il bloque l’action, et le pendule tombe. On s’attend a un score bien inferieur a 500. Comparez : la majorite est un bon compromis entre confiance et tolerance ; l’unanimite est trop stricte. Verifiez en re-executant action_bitwise (majorite) puis votre fonction unanime sur range(100).
Exercice 2 : inverser un signal
Que se passe-t-il si on inverse le signe lu pour theta (c’est-a-dire rule1 = (angle >> 31) sans le ^ 1) ? C’est l’equivalent de “croire que le mat penche a gauche quand il penche a droite”. Relevez le score.
Indice : copiez action_bitwise, retirez le ^ 1 de rule1 seulement, evaluez.
# Exercice 2 : inverser le signe lu pour l'angle# TODO etudiant : action_bitwise_invert1(obs) avec rule1 SANS le ^ 1, evaluez sur range(100)def action_bitwise_invert1(obs):pass# TODO etudiant# resultat = evaluer(action_bitwise_invert1, range(100))print("Exercice a completer")
Exercice a completer
Exemple guide : correction de l’exercice 2
Inverser le signe de theta revient a faire exactement l’inverse de la bonne decision sur l’angle : le controleur pousse le mat du mauvais cote. Le resultat est une chute immediate (score proche de la valeur minimale, 0/100). Cela scelle l’importance du bon sens physique : le bit de signe de theta doit etre lu avec le ^ 1 (r1 = (angle>>31)^1 = “le mat penche a droite”). Verifiez en comparant action_bitwise et votre fonction inversee.
Exercice 3 : robustesse au bruit — seuil de rupture
En vous inspirant de l’etude de rupture, determinez le plus petit sigma de bruit d’observation auquel le bitwise passe sous 90/100. Est-il plus petit ou plus grand que pour le LQR ? Concluez.
Indice : utilisez evaluer_perturbe(action_bitwise, range(100), sigma=s) pour s croissant, et trouvez le seuil. Comparez avec action_lqr.
# Exercice 3 : seuil de rupture en bruit d'observation# TODO etudiant : trouvez le plus petit sigma tel que bitwise < 90/100, et comparez au LQRfor s in [0.01, 0.02, 0.05]: m, sc = evaluer_perturbe(action_bitwise, range(100), sigma=s)print(f" bitwise sigma={s:.3f} : moyen={m:6.2f} succes={sc}/100")# Comparaison requise avec action_lqr.print("Exercice a completer")
Le bitwise casse sous bruit d’observation deja a sigma proche de 0.01 (il passe sous 100/100), et descend vers ~75/100 a ~0.05. Le LQR ne casse pas dans cette gamme. Le seuil du bitwise est donc bien plus bas que celui du LQR. La conclusion est la lecon du notebook : la rapidite et la “gratuite” du bitwise se paient par une fragilite au bruit de capteur — lire un bit de signe, c’est depender de la traversee de zero, la situation la plus sensible au bruit.
Conclusion
Dans ce notebook, nous avons :
lu les bits IEEE-754 d’un flottant et isole le bit de signe ((motif >> 31) & 1) ;
scelle la correspondance observation -> bits par des tests (bit de signe == signe ; dimensions CartPole) ;
construit un controleur bitwise a 4 regles : r1 (signe de l’angle), r2 (signe de la vitesse angulaire), r3 (un XOR de deux bits de la vitesse du chariot avec le signe de la vitesse angulaire), r4 = vote majoritaire — 0 parametre, 0 flottant ;
mesure qu’il atteint 500.00 / 100/100 sur trois blocs de seeds, a parite avec un controleur LQR classique et devant un PPO entraine a budget court (~456) ;
constate que bitwise et LQR desaccordent sur ~13 % des pas (pres de l’equilibre) : ce sont deux politiques differentes mais egalement bonnes ;
expose leurs faiblesses respectives : le bitwise casse sous bruit d’observation (il lit des bits de signe, la donnee la plus fragile au bruit), le LQR y resiste ; l’ablation montre qu’aucun signal seul ni en paire ne suffit, seule la majorite tient le pendule ;
rapporte les couts : 0 parametre (bitwise) vs 4 gains (LQR) vs 9155 (PPO).
Ce qu’il faut retenir
La politique optimale d’une tache simple peut se synthetiser en logique binaire — pas besoin d’un reseau de neurones pour CartPole.
Plusieurs politiques differentes peuvent egalement resoudre un probleme : bitwise et LQR sont distincts mais tous deux parfaits.
Chaque politique a sa monnaie : le bitwise est gratuit (0 parametre) mais fragile au bruit (bits de signe) ; le LQR est robuste mais demande un modele linearise ; le PPO apprend mais a un cout (9155 parametres, entrainement).
La robustesse vient de la redundancy : l’ablation montre que le vote majoritaire de trois signaux est ce qui tient le pendule, pas un signal unique.
Limites
L’etude de rupture a borne la longueur (0.35-0.70) et la masse (0.05-0.20) : aucune rupture n’y a ete observee. Une rupture apparaitrait au-dela de la bande testee ; ce n’est pas explore ici.
Le PPO est entraine a budget court (30000 pas) : a budget plus long, il atteint 500 (voir rl_1_intro_cartpole).
La latence us est dominee par l’overhead Python/numpy : la comparaison honnete est l’ordre de grandeur d’operations et le nombre de parametres, pas la latence brute.