Navigation : Index | << RL-1 Intro | RL-2 Wrappers >>

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

  1. La lecture des bits IEEE-754 d’un flottant (bit de signe).
  2. Le scellement de la correspondance observation -> bits par des tests.
  3. Le controleur bitwise 4-regles et son evaluation (500.00 sur 100 seeds).
  4. Les deux controleurs de reference : LQR (lineaire) et PPO (appris).
  5. Le desaccord entre bitwise et LQR (~13 %).
  6. L’etude de rupture (longueur, masse, bruit) et l’ablation des regles.
  7. Une discussion complexite / latence.

Objectif : pourquoi des bits ?

Une politique RL renvoie une action 0 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 np

def 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.
    return int(((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.

Scellement : correspondance observation -> bits (tests)

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 gym

resultats = []
def verifie(libelle, condition):
    resultats.append((libelle, bool(condition)))

# (a) propriete du bit de signe sur un balayage de flottants
rng = np.random.default_rng(123)
nb_echecs = 0
for _ in range(2000):
    v = float(rng.normal())
    if (1 if v < 0 else 0) != sign_bit(v):
        nb_echecs += 1
verifie("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 theta
env = 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])", (1 if float(obs[2]) < 0 else 0) == sign_bit(obs[2]))
env.close()

nb_ok = sum(1 for _, ok in resultats if ok)
nb_echouees = len(resultats) - nb_ok
print(f"\n  scellement : {nb_ok}/{len(resultats)} verifications OK ({nb_echouees} echec(s))")
for libelle, ok in resultats:
    if not ok:
        print(f"    [ECHEC] {libelle}")
  observation apres reset(seed=0) : [ 0.0137 -0.023  -0.0459 -0.0483]

  scellement : 7/7 verifications OK (0 echec(s))

Lecture du scellement

  • 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 majoritaire
    return rule4

# un test de fumee : l'action est bien 0 ou 1
print("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 = 500

def 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.0
        for _ in range(max_steps):
            obs, r, termine, tronque, _ = env.step(controleur(obs))
            total += r
            if 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}")
Controleur bitwise 4-regles
  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
  seeds 500-599 : moyen=500.00  ecart= 0.00  succes=100/100  min=500  max=500

Lecture

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_are

def 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) / total
    return 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-4
A = 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 in range(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 commande
P_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)
    return 1 if u >= 0 else 0

print("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 in range(100):
    obs, _ = env.reset(seed=s)
    for _ in range(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 += 1
env.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 PPO
from stable_baselines3.common.vec_env import DummyVecEnv

vec = 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)
    return int(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}")
  PPO entraine (30000 pas, seed 42). Params du reseau : 9155
  seeds   0- 99 : moyen=456.16  ecart=47.77  succes= 49/100  min=336  max=500
  seeds 500-599 : moyen=452.63  ecart=53.67  succes= 51/100  min=333  max=500

Lecture

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

def ligne(nom, cont, seeds):
    m, sd, sc, mn, mx = evaluer(cont, seeds)
    return f"| {nom:10s} | {m:6.2f} | {sd:5.2f} | {sc:3d}/100 | {mn} | {mx} |"

print("| Controlleur | moyen | ecart | succes | min | max |")
print("|---|---|---|---|---|---|")
print(ligne("bitwise", action_bitwise, range(100)))
print(ligne("bitwise", action_bitwise, range(500, 600)))
print(ligne("LQR", action_lqr, range(100)))
print(ligne("PPO", action_ppo, range(100)))
print(ligne("PPO", action_ppo, range(500, 600)))
| Controlleur | moyen | ecart | succes | min | max |
|---|---|---|---|---|---|
| bitwise    | 500.00 |  0.00 | 100/100 | 500 | 500 |
| bitwise    | 500.00 |  0.00 | 100/100 | 500 | 500 |
| LQR        | 500.00 |  0.00 | 100/100 | 500 | 500 |
| PPO        | 456.16 | 47.77 |  49/100 | 336 | 500 |
| PPO        | 452.63 | 53.67 |  51/100 | 333 | 500 |

Lecture du tableau

Controlleur moyen ecart succes
bitwise 500.00 0.00 100/100
LQR 500.00 0.00 100/100
PPO (30000 pas) ~456 ~48 ~49/100

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 is not None: env.unwrapped.length = length
    if mass is not None:   env.unwrapped.masspole = mass
    recompenses = []
    for s in seeds:
        obs, _ = env.reset(seed=s)
        total = 0.0
        for k in range(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 += r
            if 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")
--- Perturbation de la longueur du mat ---  (nominal L=0.50)  [moyen, succes/100]
  L      bitwise      LQR 
  0.35  500.00 100/100   500.00 100/100
  0.45  500.00 100/100   500.00 100/100
  0.50  500.00 100/100   500.00 100/100
  0.55  500.00 100/100   500.00 100/100
  0.70  500.00 100/100   500.00 100/100
--- Perturbation de la masse du mat ---  (nominal m=0.10)
  m      bitwise      LQR 
  0.05  500.00 100/100   500.00 100/100
  0.10  500.00 100/100   500.00 100/100
  0.15  500.00 100/100   500.00 100/100
  0.20  500.00 100/100   500.00 100/100
--- Bruit d'observation (sigma) ---
  sigma   bitwise      LQR 
  0.000  500.00 100/100   500.00 100/100
  0.005  500.00 100/100   500.00 100/100
  0.010  500.00 100/100   500.00 100/100
  0.020  494.90  96/100   500.00 100/100
  0.050  438.94  71/100   500.00 100/100

Lecture de l’etude de rupture

  • 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 complete full = 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.

def action_avec(variant, obs):
    u = u32(obs)
    v  = int(u[1]); an = int(u[2]); ag = int(u[3])
    r1 = (an >> 31) ^ 1
    r2 = (ag >> 31) ^ 1
    r3 = ((v >> 24) ^ (v >> 23) ^ (ag >> 31) ^ 1) & 1
    if variant == "r1":    return r1
    if variant == "r2":    return r2
    if variant == "r3":    return r3
    if variant == "drop1": return r2 & r3
    if variant == "drop2": return r1 & r3
    if variant == "drop3": return r1 & r2
    return (r1 & r2) | (r1 & r3) | (r2 & r3)   # full

print("| variante | moyen | succes |")
print("|---|---|---|")
for v in ["r1", "r2", "r3", "drop1", "drop2", "drop3", "full"]:
    m, sd, sc, mn, mx = evaluer(lambda o: action_avec(v, o), range(100))
    print(f"| {v:6s} | {m:6.2f} | {sc:3d}/100 |")
| variante | moyen | succes |
|---|---|---|
| r1     |  41.04 |   0/100 |
| r2     | 198.06 |   0/100 |
| r3     |  37.78 |   0/100 |
| drop1  |  50.30 |   0/100 |
| drop2  |  22.31 |   0/100 |
| drop3  | 163.82 |   0/100 |
| full   | 500.00 | 100/100 |

Lecture de l’ablation

variante moyen succes
r1 seul ~41 0/100
r2 seul ~198 0/100
r3 seul ~38 0/100
drop1 (r2&r3) ~50 0/100
drop2 (r1&r3) ~22 0/100
drop3 (r1&r2) ~164 0/100
full (vote majoritaire) 500.00 100/100

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 time

print(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 decision
print("  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’unanimite r1 & 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 LQR

for 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")
  bitwise sigma=0.010 : moyen=500.00 succes=100/100
  bitwise sigma=0.020 : moyen=494.90 succes=96/100
  bitwise sigma=0.050 : moyen=438.94 succes=71/100
Exercice a completer

Exemple guide : correction de l’exercice 3

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

  1. La politique optimale d’une tache simple peut se synthetiser en logique binaire — pas besoin d’un reseau de neurones pour CartPole.
  2. Plusieurs politiques differentes peuvent egalement resoudre un probleme : bitwise et LQR sont distincts mais tous deux parfaits.
  3. 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).
  4. 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.

Voir aussi

Retour au sommet