ldov
/

openjevv / code /flappy.py
ldov's picture AlexWortega's picture
Duplicate from AlexWortega/openjev
e46c127
Raw
History Blame Contribute Delete
14.2 kB
#!/usr/bin/env python
"""Vibe test: can the NLI cross-encoder (and its latent + MLP) play Flappy Bird?
The game state is rendered as text (premise); the two actions are the options ("flap" / "do nothing").
Policies:
random, never (always "do nothing"), oracle (heuristic on the true state),
nli : zero-shot, argmax P(entailment) over "The correct action is: {a}",
mlp : latent of the frozen NLI model for both options -> MLP trained with soft BCE on oracle labels
collected from noisy oracle rollouts (same recipe as latent_mlp.py).
python flappy.py --ckpt ckpt/qwen3.5-4b-nli --episodes 20 --out results/flappy_4b.json
"""
import argparse
import json
import random
import time
import numpy as np
import torch
import torch.nn as nn
from latent_mlp import MLP, fit, predict, grouped_split
# ----------------------------------------------------------------------------- game
GRAVITY, FLAP_V, SPEED = 0.003, 0.015, 0.015
PIPE_EVERY, GAP, PIPE_HW, BIRD_X, BIRD_HW = 0.45, 0.28, 0.04, 0.2, 0.03
class Flappy:
def __init__(self, seed=0, max_steps=2000):
self.rng = random.Random(seed)
self.max_steps = max_steps
self.reset()
def reset(self):
self.y, self.vy, self.t, self.score, self.done = 0.5, 0.0, 0, 0, False
self.pipes = [[1.2, self._gap()]] # [x, gap_lo]
return self.state()
def _gap(self):
return self.rng.uniform(0.15, 0.85 - GAP)
def next_pipe(self):
for x, lo in self.pipes:
if x + PIPE_HW >= BIRD_X - BIRD_HW:
return x, lo
return None
def step(self, flap):
if self.done:
return self.state(), 0.0, True
self.vy = FLAP_V if flap else self.vy - GRAVITY
self.y += self.vy
self.t += 1
for p in self.pipes:
p[0] -= SPEED
if self.pipes[-1][0] < 1.2 - PIPE_EVERY:
self.pipes.append([self.pipes[-1][0] + PIPE_EVERY, self._gap()])
if self.pipes[0][0] + PIPE_HW < BIRD_X - BIRD_HW:
self.pipes.pop(0)
self.score += 1
x, lo = self.next_pipe()
hit = self.y <= 0 or self.y >= 1
if abs(x - BIRD_X) < PIPE_HW + BIRD_HW and not (lo < self.y < lo + GAP):
hit = True
if hit or self.t >= self.max_steps:
self.done = True
return self.state(), (0.0 if hit else 1.0), self.done
def state(self):
x, lo = self.next_pipe()
return {"y": self.y, "vy": self.vy, "dx": x - BIRD_X, "lo": lo, "hi": lo + GAP, "score": self.score, "t": self.t}
def oracle(s, margin=0.03, lookahead=3):
"""flap if the predicted height a few frames ahead falls below the gap centre (minus a margin)."""
target = (s["lo"] + s["hi"]) / 2 - margin
y_pred = s["y"] + s["vy"] * lookahead - GRAVITY * lookahead * (lookahead - 1) / 2
return y_pred < target
def render_text(s):
pos = "inside the gap" if s["lo"] < s["y"] < s["hi"] else ("above the gap" if s["y"] >= s["hi"] else "below the gap")
move = "rising" if s["vy"] > 0 else "falling"
centre = (s["lo"] + s["hi"]) / 2
return (f"Flappy Bird. The bird is at height {s['y']:.2f} (0 = ground, 1 = ceiling) and is {move} "
f"with vertical velocity {s['vy']:+.3f} per frame; gravity pulls it down every frame and flapping pushes it up. "
f"The next pipe is {s['dx']:.2f} ahead; its gap spans heights {s['lo']:.2f} to {s['hi']:.2f} (centre {centre:.2f}). "
f"The bird is currently {pos}, {s['y'] - centre:+.2f} relative to the gap centre. "
f"The bird must fly through the gap without touching the pipe, the ground or the ceiling.")
def render_numeric(s):
return (f"Flappy Bird state: y={s['y']:.2f} vy={s['vy']:+.3f} pipe_dx={s['dx']:.2f} "
f"gap_lo={s['lo']:.2f} gap_hi={s['hi']:.2f} gap_centre={(s['lo'] + s['hi']) / 2:.2f} "
f"offset_from_centre={s['y'] - (s['lo'] + s['hi']) / 2:+.2f}")
def render_coach(s):
centre = (s["lo"] + s["hi"]) / 2
off = s["y"] - centre
move = "rising" if s["vy"] > 0 else "falling"
return (f"Flappy Bird. Rule of thumb: flap when the bird is below the centre of the next gap or falling towards it; "
f"do nothing when it is above the centre or rising. Right now the bird is {abs(off):.2f} {'above' if off > 0 else 'below'} "
f"the gap centre and {move} at {abs(s['vy']):.3f} per frame. The pipe is {s['dx']:.2f} ahead.")
def render_ascii_prompt(s):
return "Flappy Bird screen (the bird is '>', pipes are '#', top row is the ceiling, bottom row is the ground):\n" + render_ascii(s)
PROMPTS = {"base": render_text, "numeric": render_numeric, "coach": render_coach, "ascii": render_ascii_prompt}
HYPS = {"action": lambda a: f"The correct action is: {a}",
"should": lambda a: "The bird should flap now." if a == "flap" else "The bird should not flap now.",
# statements about the state that map to actions (zero-shot: NLI only has to verify the statement)
"position": lambda a: "The bird is below the centre of the gap." if a == "flap" else "The bird is above the centre of the gap.",
"position_v": lambda a: ("The bird is below the centre of the gap, or it is falling fast." if a == "flap"
else "The bird is above the centre of the gap and not falling fast."),
"sign": lambda a: "The offset relative to the gap centre is negative." if a == "flap" else "The offset relative to the gap centre is positive."}
def render_ascii(s, h=12, w=30):
grid = [[" "] * w for _ in range(h)]
col = int(min(max(s["dx"] / 0.6, 0), 1) * (w - 1))
for r in range(h):
yy = 1 - r / (h - 1)
if not (s["lo"] < yy < s["hi"]):
grid[r][col] = "#"
br = int(round((1 - s["y"]) * (h - 1)))
if 0 <= br < h:
grid[br][2] = ">"
return "\n".join("".join(r) for r in grid)
ACTIONS = ["flap", "do nothing"]
# ----------------------------------------------------------------------------- model-backed policies
class Scorer:
def __init__(self, ckpt, prompt="base", hyp="action"):
self.render, self.hyp = PROMPTS[prompt], HYPS[hyp]
from transformers import AutoModelForSequenceClassification, AutoTokenizer
self.tok = AutoTokenizer.from_pretrained(ckpt)
self.model = AutoModelForSequenceClassification.from_pretrained(ckpt, dtype=torch.bfloat16).cuda().eval()
self.template = getattr(self.model.config, "nli_template", None) or "Premise: {premise}\nHypothesis: {hypothesis}"
if self.model.config.get_text_config().pad_token_id is None:
self.model.config.get_text_config().pad_token_id = self.tok.pad_token_id
self.tok.padding_side = "right"
self.backbone = getattr(self.model, self.model.base_model_prefix)
@torch.no_grad()
def latents(self, texts, bs=64):
X, L = [], []
for s in range(0, len(texts), bs):
enc = self.tok(texts[s:s + bs], truncation=True, max_length=512, padding=True, return_tensors="pt").to("cuda")
h = self.backbone(**enc).last_hidden_state
last = enc["attention_mask"].sum(1) - 1
pooled = h[torch.arange(h.shape[0], device=h.device), last]
X.append(pooled.float().cpu().numpy()); L.append(self.model.score(pooled).float().cpu().numpy())
return np.concatenate(X), np.concatenate(L)
def pair_texts(self, s):
return [self.template.format(premise=self.render(s), hypothesis=self.hyp(a)) for a in ACTIONS]
def play(env_seed, policy, max_steps, fps=None, record=False):
"""fps=None: turn-based (the game waits for the policy). fps=30: real time - while the policy is thinking the
game keeps ticking with no flap, so a slow policy acts on stale states and skips frames."""
env = Flappy(seed=env_seed, max_steps=max_steps)
s = env.reset()
frames, replay, lats, skipped_total = [], [], [], 0
def snap(a, lat_ms, skipped, probs=None):
if record:
replay.append({"t": env.t, "y": round(env.y, 4), "pipes": [[round(x, 3), round(lo, 3)] for x, lo in env.pipes[:3]],
"a": int(a), "lat_ms": round(lat_ms, 1), "skipped": skipped, "score": env.score,
"probs": [round(float(x), 4) for x in probs] if probs is not None else None})
while not env.done:
t0 = time.perf_counter()
a = policy(s)
probs = None
if isinstance(a, tuple):
a, probs = a
lat = time.perf_counter() - t0
lats.append(lat)
skipped = int(lat * fps) if fps else 0
if record and env.t % 3 == 0 and len(frames) < 12:
frames.append((render_ascii(s), "FLAP" if a else "----"))
snap(a, lat * 1000, 0, probs)
s, _, _ = env.step(a)
for _ in range(skipped): # model still thinking: bird glides
if env.done:
break
skipped_total += 1
snap(False, 0.0, 1)
s, _, _ = env.step(False)
return {"score": env.score, "steps": env.t, "frames": frames, "replay": replay,
"lat_ms": float(np.mean(lats) * 1000), "skipped": skipped_total, "decisions": len(lats)}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--ckpt", default="ckpt/qwen3.5-4b-nli")
ap.add_argument("--episodes", type=int, default=20)
ap.add_argument("--max-steps", type=int, default=1500)
ap.add_argument("--collect-episodes", type=int, default=60, help="noisy oracle rollouts for MLP training data")
ap.add_argument("--noise", type=float, default=0.15)
ap.add_argument("--eps", type=float, default=0.1)
ap.add_argument("--out", default="results/flappy.json")
ap.add_argument("--seed", type=int, default=0)
ap.add_argument("--fps", type=float, default=30.0, help="real-time tick rate; 0 = turn-based")
ap.add_argument("--record-only", action="store_true", help="few episodes, replay with probabilities (for the video)")
ap.add_argument("--prompt", default="base", choices=list(PROMPTS))
ap.add_argument("--hyp", default="action", choices=list(HYPS))
ap.add_argument("--lookahead", type=int, default=3, help="oracle lookahead used for labels and the oracle policy")
ap.add_argument("--margin", type=float, default=0.03)
ap.add_argument("--skip-nli", action="store_true")
ap.add_argument("--zero-shot-only", action="store_true", help="stop after the zero-shot NLI policy (no MLP)")
args = ap.parse_args()
fps = args.fps or None
rng = random.Random(args.seed)
results = {}
def evaluate(name, policy, record_first=True):
eps = [play(1000 + i, policy, args.max_steps, fps=fps, record=record_first) for i in range(args.episodes)]
best = max(eps, key=lambda e: e["score"]) # keep the replay of the best episode
sc = [e["score"] for e in eps]
lat = float(np.mean([e["lat_ms"] for e in eps])); skip = float(np.mean([e["skipped"] / max(e["steps"], 1) for e in eps]))
results[name] = {"mean_score": float(np.mean(sc)), "median_score": float(np.median(sc)), "max_score": int(max(sc)),
"mean_steps": float(np.mean([e["steps"] for e in eps])), "lat_ms": lat, "skipped_frac": skip,
"frames": best["frames"], "replay": best["replay"], "replay_score": best["score"]}
print(f"{name:10s} score mean {np.mean(sc):6.2f} median {np.median(sc):5.1f} max {max(sc):3d} steps {np.mean([e['steps'] for e in eps]):7.1f}"
f" latency {lat:6.1f} ms skipped frames {skip:5.1%}", flush=True)
if not args.record_only:
evaluate("random", lambda s: rng.random() < 0.1)
evaluate("never", lambda s: False)
orc = lambda s: oracle(s, margin=args.margin, lookahead=args.lookahead)
evaluate("oracle", orc)
scorer = Scorer(args.ckpt, args.prompt, args.hyp)
# zero-shot NLI: argmax entailment over the two action hypotheses
def nli_policy(s):
_, L = scorer.latents(scorer.pair_texts(s))
p = torch.softmax(torch.tensor(L), -1).numpy()
return int(p[:, 1].argmax()) == 0, p[:, 1] # index 0 = flap; probs = P(entailment) per option
if not args.skip_nli:
t0 = time.time(); evaluate("nli", nli_policy); print(f" ({time.time()-t0:.0f}s)")
if args.zero_shot_only:
json.dump({"args": vars(args), "results": results}, open(args.out, "w"), indent=2)
return
# latent + MLP trained on noisy-oracle rollouts
states, labels = [], []
for i in range(args.collect_episodes):
env = Flappy(seed=i, max_steps=600); s = env.reset()
while not env.done:
a_or = orc(s)
states.append(dict(s)); labels.append(int(a_or))
a = (not a_or) if rng.random() < args.noise else a_or
s, _, _ = env.step(a)
print(f"collected {len(states)} states, flap rate {np.mean(labels):.2f}", flush=True)
texts = [t for s in states for t in scorer.pair_texts(s)]
X, _ = scorer.latents(texts)
qid = np.repeat(np.arange(len(states)), 2)
gold = np.array([[1, 0] if l == 1 else [0, 1] for l in labels]).ravel()
tr, va = grouped_split(qid, 0.1, args.seed)
ns = argparse.Namespace(hidden=512, dropout=0.1, lr=1e-3, wd=1e-2, bs=512, epochs=60, patience=8, eps=args.eps, seed=args.seed)
model, stats, va_acc, _ = fit(X[tr], gold[tr], qid[tr], X[va], gold[va], qid[va], ns)
print(f"mlp val agreement with oracle: {va_acc:.3f}", flush=True)
results["mlp_val_acc"] = va_acc
def mlp_policy(s):
Xs, _ = scorer.latents(scorer.pair_texts(s))
z = predict(model, stats, Xs)
return int(z.argmax()) == 0, 1 / (1 + np.exp(-z)) # probs = sigmoid score per option (flap, do nothing)
t0 = time.time(); evaluate("mlp", mlp_policy); print(f" ({time.time()-t0:.0f}s)")
json.dump({"args": vars(args), "results": results}, open(args.out, "w"), indent=2)
for name in [n for n in ["nli", "mlp"] if n in results]:
print(f"\n=== {name}: first frames of episode 0 (bird '>', pipe '#')")
for fr, a in results[name]["frames"][:4]:
print(fr); print("action:", a); print("-" * 30)
if __name__ == "__main__":
main()