lunarlander-lin32 / model.py
Dimitrius174's picture
Replace with max-safety weights (start 1 of the 6-run frontier): mean 231.2, landings 99.67%, crashes 0.25% - 23x fewer crashes than the teacher
bb8222a verified
Raw History Blame Contribute Delete
3.6 kB
#!/usr/bin/env python3
"""TinyLunarPolicy β€” a 32-parameter single-layer neural network that lands LunarLander-v3.
logits = Linear(8 -> 4, bias=False)(obs) # 32 parameters
action = argmax(logits)
This is the *minimal* policy that solves Gymnasium `LunarLander-v3`:
mean return ~231.2, 99.7% landings (0.25% crashes), 60/60 evaluation seeds solved (see README).
It is exactly a single linear layer + argmax, and is mathematically identical to a
single-centroid nearest-neighbour policy in the C2 space (s (x) onehot(a)).
Dependencies: torch, safetensors (numpy optional, only for `.act`).
"""
from __future__ import annotations
import numpy as np
import torch
import torch.nn as nn
OBS_DIM = 8
N_ACTIONS = 4
# Gymnasium LunarLander-v3 observation layout (for interpretability only).
OBS_NAMES = [
"x", "y", "vx", "vy", "angle", "angular_velocity",
"leg_left_contact", "leg_right_contact",
]
class TinyLunarPolicy(nn.Module):
"""Linear 8->4 policy head; the action is the argmax of the four logits."""
def __init__(self, obs_dim: int = OBS_DIM, n_actions: int = N_ACTIONS,
bias: bool = False) -> None:
super().__init__()
self.obs_dim = int(obs_dim)
self.n_actions = int(n_actions)
self.linear = nn.Linear(self.obs_dim, self.n_actions, bias=bias)
# ── introspection ────────────────────────────────────────────────────────
@property
def n_params(self) -> int:
return sum(p.numel() for p in self.parameters())
def weights(self) -> np.ndarray:
"""Return W of shape (obs_dim, n_actions) such that logits = obs @ W."""
return self.linear.weight.detach().cpu().numpy().T # (8, 4)
# ── policy ───────────────────────────────────────────────────────────────
def forward(self, obs: torch.Tensor) -> torch.Tensor:
"""obs: (..., 8) -> logits (..., 4)."""
return self.linear(obs)
@torch.no_grad()
def logits(self, obs) -> torch.Tensor:
o = torch.as_tensor(np.asarray(obs, dtype=np.float32))
return self.forward(o)
@torch.no_grad()
def act(self, obs) -> int:
"""Greedy action for a single observation (array-like of length 8)."""
return int(torch.argmax(self.logits(obs)).item())
# ── loading ──────────────────────────────────────────────────────────────
@classmethod
def from_safetensors(cls, path: str, **kwargs) -> "TinyLunarPolicy":
"""Load from a local `.safetensors` file."""
from safetensors.torch import load_file
model = cls(**kwargs)
model.load_state_dict(load_file(path))
model.eval()
return model
@classmethod
def from_pretrained(cls, repo_id_or_path: str = "Dimitrius174/lunarlander-lin32",
filename: str = "model.safetensors", **kwargs) -> "TinyLunarPolicy":
"""Load from the Hugging Face Hub (or a local directory)."""
import os
if os.path.isdir(repo_id_or_path):
return cls.from_safetensors(os.path.join(repo_id_or_path, filename), **kwargs)
from huggingface_hub import hf_hub_download
return cls.from_safetensors(hf_hub_download(repo_id_or_path, filename), **kwargs)