Dimitrius174 commited on
Commit
ae4162b
·
0 Parent(s):

Initial commit

Browse files
Files changed (11) hide show
  1. .gitattributes +35 -0
  2. LICENSE +21 -0
  3. README.md +199 -0
  4. config.json +39 -0
  5. configuration_tiny.py +12 -0
  6. landing_check.py +52 -0
  7. model.py +81 -0
  8. model.safetensors +3 -0
  9. modeling_tiny.py +37 -0
  10. requirements.txt +8 -0
  11. verify.py +88 -0
.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dmitry T (Dimitrius174)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ library_name: pytorch
4
+ tags:
5
+ - reinforcement-learning
6
+ - lunarlander
7
+ - gymnasium
8
+ - tiny-model
9
+ - linear-policy
10
+ - model-compression
11
+ ---
12
+
13
+ # LunarLander-v3 in 32 parameters (max-safety)
14
+
15
+ A **32-parameter single-layer neural network** that solves Gymnasium
16
+ [`LunarLander-v3`](https://gymnasium.farama.org/environments/box2d/lunar_lander/):
17
+ **231.2 mean return, 99.7% landings, 60/60 seeds**, crashing in only ~0.25% of episodes.
18
+
19
+ ```
20
+ logits = Linear(8 -> 4, bias=False)(obs) # 32 parameters
21
+ action = argmax(logits)
22
+ ```
23
+
24
+ The weights shipped here are the **max-safety** variant (see *Choice of objective* below).
25
+
26
+ ## Evaluation
27
+
28
+ 60 evaluation seeds x 100 episodes = 6000 episodes, deterministic greedy policy:
29
+
30
+ | metric | **max-safety** (this model) | crash-averse | mean-optimal teacher |
31
+ |---|---|---|---|
32
+ | mean return | 231.23 | 239.46 | 247.20 |
33
+ | std over seeds | 3.39 | 2.87 | 5.89 |
34
+ | min / max seed mean | 223.30 / 238.74 | 232.21 / 244.62 | 232.78 / 260.70 |
35
+ | seeds with mean >= 200 | **60 / 60** | 60 / 60 | 60 / 60 |
36
+ | landing rate (env `+100`) | **99.67%** | 99.42% | 94.10% |
37
+ | crash rate (env `-100`) | **0.25%** | 0.57% | 5.88% |
38
+ | parameters | 32 | 32 | 32 |
39
+
40
+ Reference random policy on this env is about -100..-500; the "solved" threshold
41
+ is 200. Reproduce with:
42
+
43
+ ```bash
44
+ pip install -r requirements.txt
45
+ python verify.py --episodes 100 --seeds 60
46
+ ```
47
+
48
+ **What "landing rate" means.** An episode ends with the environment's own ±100 signal:
49
+ `+100` when the lander comes to rest (both legs grounded, low speed, small angle) — a *safe
50
+ landing*; `-100` when the lander's body hits the ground or it flies off screen — a *crash*
51
+ (see `LunarLander._get_reward`). "Landing rate" is the fraction of episodes that received the
52
+ `+100` verdict. Measured exactly on the 6000 episodes:
53
+
54
+ | verdict | episodes | share |
55
+ |---|---|---|
56
+ | landed (env `+100`) | 5980 | 99.67% |
57
+ | crashed (env `-100`) | 15 | 0.25% |
58
+ | timeout (1000 steps) | 5 | 0.08% |
59
+
60
+ The `return >= 100` proxy (99.67%) agrees with the environment's own verdict on **6000 / 6000**
61
+ episodes. `python landing_check.py` reproduces this cross-check.
62
+
63
+ Because a safe landing is worth roughly +100..+320 (the terminal +100 plus shaping and leg
64
+ contacts), the "solved" criterion (mean >= 200) is equivalent to "lands almost every time".
65
+
66
+ ## Choice of objective (why 231 and not 247)
67
+
68
+ In this environment **average score and reliability trade off**, and the shipped weights pick
69
+ maximum safety. Starting from the mean-optimal teacher (247.2 mean, 5.9% crashes) the weights were
70
+ refined against a crash-penalised objective (`mean - 500 * crash_rate`) on a *separate* train
71
+ seed set, evaluating on held-out seeds. On the 60 canonical evaluation seeds:
72
+
73
+ | | mean | landings | crashes |
74
+ |---|---|---|---|
75
+ | mean-optimal teacher | 247.2 | 94.1% | 5.9% |
76
+ | crash-averse | 239.5 | 99.4% | 0.6% |
77
+ | **max-safety** (this model) | 231.2 | **99.7%** | **0.25%** |
78
+
79
+ The crashes are not caused by a few "bad seeds" — all 60 seeds pass, and failures are spread
80
+ over episodes (2-11 crashes out of 100 per seed); they start from faster, more tilted spawns.
81
+ They are also *policy-addressable*: the same 32-parameter class contains a policy with ~20x
82
+ fewer crashes, so 5.9% is a compromise of one point, not a hard limit of the class. Reliability
83
+ is bought with mean return — within the same 32 parameters.
84
+
85
+ Earlier revisions (crash-averse 239.5 / 0.6%; mean-optimal 247.2 / 5.9%) are preserved in this
86
+ repository's git history.
87
+
88
+ ### Reproducibility (why not to over-trust the crash rate)
89
+
90
+ The max-safety weights above are **start 1** of an experiment with 6 independent optimisation
91
+ runs of the same objective (`mean - 500 * crash_rate`). The six runs land at very different
92
+ points, and several barely move from the teacher at all:
93
+
94
+ | start | TEST mean | TEST crash rate |
95
+ |---|---|---|
96
+ | 0 | 247.7 | 5.7% |
97
+ | 1 | 231.6 | **0.22%** |
98
+ | 2 | 250.1 | 2.0% |
99
+ | 3 | 246.8 | 6.7% |
100
+ | 4 | **258.1** | 3.5% |
101
+ | 5 | 237.7 | 4.0% |
102
+ | (teacher) | 248.3 | 5.5% |
103
+
104
+ All six still solve every seed (>= 230 mean, 60/60), so the *regime* is safe — but the crash rate
105
+ is noisy: only 2 of 6 starts reached <= 2%. Reproducing a specific crash rate is therefore **not
106
+ guaranteed** at this small optimisation budget; run more starts (or a larger budget) if you need a
107
+ target reliability. Two starts **Pareto-dominate the teacher on both axes** (start 2: 250.1 / 2.0%;
108
+ start 4: 258.1 / 3.5%), i.e. higher score *and* fewer crashes than the mean-optimal policy.
109
+
110
+ ### Out-of-sample check (fresh seeds)
111
+
112
+ Because the shipped weights were *selected* as the best of those 6 runs, the numbers above were
113
+ re-checked on entirely fresh seeds — three disjoint blocks of 60 seeds x 100 episodes
114
+ (18,000 episodes per policy) that were used neither for training nor for selection:
115
+
116
+ | policy | mean | landings | crashes | seeds >= 200 |
117
+ |---|---|---|---|---|
118
+ | **max-safety** (this model) | **231.4** | **99.83%** | **0.17%** | 60/60 in every block |
119
+ | mean-optimal teacher | 246.0 | 93.9% | 6.0% | 60/60 |
120
+ | start 2 (kept in history) | 248.6 | 97.7% | 2.3% | 60/60 |
121
+ | start 4 (kept in history) | **258.0** | 96.3% | 3.5% | 60/60 |
122
+
123
+ The shipped model reproduces its canonical numbers on fresh seeds (231.4 vs 231.2 mean; 0.17% vs
124
+ 0.25% crashes), so its low crash rate is a property of the weights, not of the seed set used to
125
+ pick them. The two variants kept in history also hold up out-of-sample and **Pareto-dominate the
126
+ mean-optimal teacher on both axes** (higher score *and* fewer crashes).
127
+
128
+ ## Usage
129
+
130
+ Standalone (only `torch` + `safetensors`):
131
+
132
+ ```python
133
+ from model import TinyLunarPolicy
134
+
135
+ policy = TinyLunarPolicy.from_pretrained("Dimitrius174/lunarlander-lin32")
136
+ # or: TinyLunarPolicy.from_safetensors("model.safetensors")
137
+
138
+ action = policy.act([0.0, 1.4, 0.0, -0.6, 0.0, 0.0, 0.0, 0.0]) # int in 0..3
139
+ ```
140
+
141
+ With `transformers` (custom code via `auto_map`):
142
+
143
+ ```python
144
+ import torch
145
+ from transformers import AutoModel
146
+
147
+ model = AutoModel.from_pretrained("Dimitrius174/lunarlander-lin32", trust_remote_code=True).eval()
148
+ logits = model(torch.tensor([0.0, 1.4, 0.0, -0.6, 0.0, 0.0, 0.0, 0.0])) # shape (4,)
149
+ action = int(logits.argmax())
150
+ ```
151
+
152
+ Gymnasium loop:
153
+
154
+ ```python
155
+ import gymnasium as gym
156
+ from model import TinyLunarPolicy
157
+
158
+ env = gym.make("LunarLander-v3")
159
+ policy = TinyLunarPolicy.from_pretrained("Dimitrius174/lunarlander-lin32")
160
+ obs, _ = env.reset(seed=0)
161
+ done = False
162
+ while not done:
163
+ obs, _, term, trunc, _ = env.step(policy.act(obs))
164
+ done = term or trunc
165
+ ```
166
+
167
+ ## Observation and action space
168
+
169
+ `obs` (8,): `x, y, vx, vy, angle, angular_velocity, leg_left_contact, leg_right_contact`.
170
+ `action` (4): `do nothing`, `fire left engine`, `fire main engine`, `fire right engine`.
171
+
172
+ ## Is 32 really minimal?
173
+
174
+ In a capacity study of small networks fitted to this policy (same data,
175
+ optimiser and steps; verified on the same 60 seeds), the findings were:
176
+
177
+ * the linear 32-parameter model was the **only** one that reproduced the policy under
178
+ every regime tried (activation, initialisation, training budget);
179
+ * a 1-hidden-layer `tanh` MLP needed **~43-56** parameters (43 borderline, 56 reliable);
180
+ * `ReLU`-family MLPs at the same budget were **unreliable** (some seeds solve, most do
181
+ not), so extra non-linearity costs parameters *and* training fragility;
182
+ * beyond ~56 parameters nothing improves (the environment, not the network, sets the ceiling).
183
+
184
+ Buying a linear layer instead of an MLP is therefore the cheap and robust choice.
185
+
186
+ ## Files
187
+
188
+ | file | what |
189
+ |---|---|
190
+ | `model.py` | standalone `TinyLunarPolicy` (`nn.Module`), no extra deps |
191
+ | `modeling_tiny.py`, `configuration_tiny.py` | `transformers` wrappers (`auto_map`, `trust_remote_code`) |
192
+ | `config.json` | architecture + env metadata |
193
+ | `model.safetensors` | the 32 weights (`linear.weight`, shape 4x8) — max-safety variant |
194
+ | `verify.py` | structural checks + live Gymnasium evaluation |
195
+ | `landing_check.py` | cross-checks the "landing rate" against the environment's own ±100 verdict |
196
+
197
+ ## License
198
+
199
+ MIT. See `LICENSE`.
config.json ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": ["TinyLunarPolicy"],
3
+ "model_type": "tiny-lunar-policy",
4
+ "auto_map": {
5
+ "AutoConfig": "configuration_tiny.TinyLunarConfig",
6
+ "AutoModel": "modeling_tiny.TinyLunarPolicy"
7
+ },
8
+ "obs_dim": 8,
9
+ "n_actions": 4,
10
+ "bias": false,
11
+ "n_params": 32,
12
+ "variant": "max-safety",
13
+ "metrics_eval_60x100": {
14
+ "mean_return": 231.227,
15
+ "std_over_seeds": 3.394,
16
+ "seeds_with_mean_ge_200": "60/60",
17
+ "landing_rate": 0.9967,
18
+ "crash_rate": 0.0025,
19
+ "episodes": 6000
20
+ },
21
+ "env_id": "LunarLander-v3",
22
+ "observation_names": [
23
+ "x",
24
+ "y",
25
+ "vx",
26
+ "vy",
27
+ "angle",
28
+ "angular_velocity",
29
+ "leg_left_contact",
30
+ "leg_right_contact"
31
+ ],
32
+ "action_meanings": [
33
+ "do nothing",
34
+ "fire left orientation engine",
35
+ "fire main engine",
36
+ "fire right orientation engine"
37
+ ],
38
+ "torch_dtype": "float32"
39
+ }
configuration_tiny.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hugging Face configuration for TinyLunarPolicy (32-param linear LunarLander policy)."""
2
+ from transformers import PretrainedConfig
3
+
4
+
5
+ class TinyLunarConfig(PretrainedConfig):
6
+ model_type = "tiny-lunar-policy"
7
+
8
+ def __init__(self, obs_dim: int = 8, n_actions: int = 4, bias: bool = False, **kwargs):
9
+ self.obs_dim = int(obs_dim)
10
+ self.n_actions = int(n_actions)
11
+ self.bias = bool(bias)
12
+ super().__init__(**kwargs)
landing_check.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Что значит «landing rate 94.1%»: сверяем прокси-порог (return>=100) с ВЕРДИКТОМ САМОЙ СРЕДЫ.
3
+
4
+ В gymnasium LunarLander на терминальном шаге награда ПЕРЕЗАПИСЫВАЕТСЯ:
5
+ reward = -100 если game_over или |x|>=1.0 (крушение / вылет)
6
+ reward = +100 если not lander.awake (безопасная посадка: аппарат замер)
7
+ Поэтому категорию эпизода можно прочитать точно, по последней награде.
8
+ """
9
+ import os, sys, warnings, numpy as np, gymnasium as gym
10
+ warnings.filterwarnings('ignore')
11
+ HERE = os.path.dirname(os.path.abspath(__file__))
12
+ sys.path.insert(0, HERE)
13
+ from model import TinyLunarPolicy
14
+ from verify import EVAL_SEEDS
15
+
16
+ pol = TinyLunarPolicy.from_safetensors(os.path.join(HERE, 'model.safetensors'))
17
+ env = gym.make('LunarLander-v3')
18
+
19
+ rows = []
20
+ for s in EVAL_SEEDS:
21
+ for ep in range(100):
22
+ o, _ = env.reset(seed=int(s) * 1000 + ep)
23
+ done, tot, last, term, trunc = False, 0.0, None, False, False
24
+ while not done:
25
+ o, r, term, trunc, info = env.step(pol.act(o))
26
+ tot += r
27
+ last = r
28
+ done = term or trunc
29
+ if term and not trunc:
30
+ cat = 'landed' if last == 100.0 else ('crashed' if last == -100.0 else 'other')
31
+ else:
32
+ cat = 'timeout'
33
+ rows.append((tot, cat))
34
+
35
+ tot = np.array([x[0] for x in rows], float)
36
+ cat = np.array([x[1] for x in rows])
37
+ n = len(rows)
38
+ print(f'эпизодов: {n}')
39
+ for k in ('landed', 'crashed', 'timeout', 'other'):
40
+ if (cat == k).any():
41
+ print(f' вердикт среды {k:8s}: {int((cat==k).sum()):5d} { (cat==k).mean():.4f}')
42
+ print(f' mean return : {tot.mean():.3f} (эталон 247.198)')
43
+ proxy = tot >= 100
44
+ print(f'\nпрокси return>=100 : {proxy.mean():.4f} ({int(proxy.sum())}/{n})')
45
+ print(f'среда landed : {(cat=="landed").mean():.4f} ({int((cat=="landed").sum())}/{n})')
46
+ print('\nтаблица сопряжённости (прокси x вердикт):')
47
+ print(f' return>=100 & landed : {int((proxy&(cat=="landed")).sum())}')
48
+ print(f' return>=100 & НЕ landed : {int((proxy&(cat!="landed")).sum())} <- ложные посадки')
49
+ print(f' return<100 & landed : {int((~proxy&(cat=="landed")).sum())} <- пропущенные посадки')
50
+ print(f' return<100 & НЕ landed : {int((~proxy&(cat!="landed")).sum())}')
51
+ print(f'\nсовпадение прокси и вердикта среды: {int((proxy==(cat=="landed")).sum())}/{n} '
52
+ f'({(proxy==(cat=="landed")).mean()*100:.2f}%)')
model.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """TinyLunarPolicy — a 32-parameter single-layer neural network that lands LunarLander-v3.
3
+
4
+ logits = Linear(8 -> 4, bias=False)(obs) # 32 parameters
5
+ action = argmax(logits)
6
+
7
+ This is the *minimal* policy that solves Gymnasium `LunarLander-v3`:
8
+ mean return ~231.2, 99.7% landings (0.25% crashes), 60/60 evaluation seeds solved (see README).
9
+ It is exactly a single linear layer + argmax.
10
+
11
+ Dependencies: torch, safetensors (numpy optional, only for `.act`).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import numpy as np
16
+ import torch
17
+ import torch.nn as nn
18
+
19
+ OBS_DIM = 8
20
+ N_ACTIONS = 4
21
+
22
+ # Gymnasium LunarLander-v3 observation layout (for interpretability only).
23
+ OBS_NAMES = [
24
+ "x", "y", "vx", "vy", "angle", "angular_velocity",
25
+ "leg_left_contact", "leg_right_contact",
26
+ ]
27
+
28
+
29
+ class TinyLunarPolicy(nn.Module):
30
+ """Linear 8->4 policy head; the action is the argmax of the four logits."""
31
+
32
+ def __init__(self, obs_dim: int = OBS_DIM, n_actions: int = N_ACTIONS,
33
+ bias: bool = False) -> None:
34
+ super().__init__()
35
+ self.obs_dim = int(obs_dim)
36
+ self.n_actions = int(n_actions)
37
+ self.linear = nn.Linear(self.obs_dim, self.n_actions, bias=bias)
38
+
39
+ # ── introspection ────────────────────────────────────────────────────────
40
+ @property
41
+ def n_params(self) -> int:
42
+ return sum(p.numel() for p in self.parameters())
43
+
44
+ def weights(self) -> np.ndarray:
45
+ """Return W of shape (obs_dim, n_actions) such that logits = obs @ W."""
46
+ return self.linear.weight.detach().cpu().numpy().T # (8, 4)
47
+
48
+ # ── policy ───────────────────────────────────────────────────────────────
49
+ def forward(self, obs: torch.Tensor) -> torch.Tensor:
50
+ """obs: (..., 8) -> logits (..., 4)."""
51
+ return self.linear(obs)
52
+
53
+ @torch.no_grad()
54
+ def logits(self, obs) -> torch.Tensor:
55
+ o = torch.as_tensor(np.asarray(obs, dtype=np.float32))
56
+ return self.forward(o)
57
+
58
+ @torch.no_grad()
59
+ def act(self, obs) -> int:
60
+ """Greedy action for a single observation (array-like of length 8)."""
61
+ return int(torch.argmax(self.logits(obs)).item())
62
+
63
+ # ── loading ──────────────────────────────────────────────────────────────
64
+ @classmethod
65
+ def from_safetensors(cls, path: str, **kwargs) -> "TinyLunarPolicy":
66
+ """Load from a local `.safetensors` file."""
67
+ from safetensors.torch import load_file
68
+ model = cls(**kwargs)
69
+ model.load_state_dict(load_file(path))
70
+ model.eval()
71
+ return model
72
+
73
+ @classmethod
74
+ def from_pretrained(cls, repo_id_or_path: str = "Dimitrius174/lunarlander-lin32",
75
+ filename: str = "model.safetensors", **kwargs) -> "TinyLunarPolicy":
76
+ """Load from the Hugging Face Hub (or a local directory)."""
77
+ import os
78
+ if os.path.isdir(repo_id_or_path):
79
+ return cls.from_safetensors(os.path.join(repo_id_or_path, filename), **kwargs)
80
+ from huggingface_hub import hf_hub_download
81
+ return cls.from_safetensors(hf_hub_download(repo_id_or_path, filename), **kwargs)
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7c7a1dae65f52640896e4a432012e461db3faaed3a8bd760025dbcc2f6645fce
3
+ size 208
modeling_tiny.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hugging Face `PreTrainedModel` wrapper for TinyLunarPolicy (auto_map / trust_remote_code).
2
+
3
+ Usage:
4
+ from transformers import AutoModel
5
+ model = AutoModel.from_pretrained("<repo>", trust_remote_code=True)
6
+ logits = model(torch.tensor(obs, dtype=torch.float32)) # (4,)
7
+ action = int(logits.argmax())
8
+ """
9
+ import torch
10
+ import torch.nn as nn
11
+ from transformers import PreTrainedModel
12
+
13
+ from configuration_tiny import TinyLunarConfig
14
+
15
+
16
+ class TinyLunarPolicy(PreTrainedModel):
17
+ config_class = TinyLunarConfig
18
+ # no tied weights; these two forms keep compatibility across transformers v4 and v5
19
+ _tied_weights_keys = []
20
+ all_tied_weights_keys = {}
21
+
22
+ def __init__(self, config: TinyLunarConfig):
23
+ super().__init__(config)
24
+ self.linear = nn.Linear(config.obs_dim, config.n_actions, bias=config.bias)
25
+
26
+ @property
27
+ def n_params(self) -> int:
28
+ return sum(p.numel() for p in self.parameters())
29
+
30
+ def forward(self, obs: torch.Tensor) -> torch.Tensor:
31
+ """obs: (..., obs_dim) -> logits (..., n_actions)."""
32
+ return self.linear(obs)
33
+
34
+ @torch.no_grad()
35
+ def act(self, obs) -> int:
36
+ o = torch.as_tensor(obs, dtype=torch.float32)
37
+ return int(self.forward(o).argmax().item())
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ torch>=2.0
2
+ safetensors>=0.4
3
+ numpy
4
+ huggingface_hub>=0.20
5
+ # optional: the transformers AutoModel entry point (config.json auto_map)
6
+ transformers>=4.40
7
+ # optional: live evaluation of the policy in Gymnasium
8
+ gymnasium[box2d]>=0.29
verify.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Reproduce the reported numbers for TinyLunarPolicy.
3
+
4
+ python verify.py # structural checks only (torch only)
5
+ python verify.py --episodes 100 # + live Gymnasium evaluation
6
+ python verify.py --seeds 60 --episodes 100 # full 60x100 protocol (mean ~247.2)
7
+
8
+ The reported reference numbers use the 60 evaluation seeds listed in README
9
+ (60 seeds x 100 episodes = 6000 episodes): mean 231.23, 99.67% landings, 0.25% crashes, 60/60 solved.
10
+ """
11
+ from __future__ import annotations
12
+ import argparse
13
+ import os
14
+
15
+ import numpy as np
16
+
17
+ from model import TinyLunarPolicy, OBS_DIM, N_ACTIONS
18
+
19
+ # The 60 evaluation seeds used for the reported reference numbers
20
+ # (episode seed = seed * 1000 + episode_index).
21
+ EVAL_SEEDS = [
22
+ 40122, 42940, 51305, 55316, 60944, 61197, 93978, 96327, 103680, 157789,
23
+ 184578, 197504, 208678, 250957, 289579, 296198, 296953, 309957, 318961, 323741,
24
+ 349217, 350692, 373376, 382648, 383930, 385293, 393420, 400382, 414089, 450829,
25
+ 467030, 482346, 490224, 498162, 501685, 515927, 535096, 543079, 544042, 553850,
26
+ 555722, 571111, 615137, 634353, 642133, 660500, 665066, 684081, 732008, 743613,
27
+ 800434, 816622, 819515, 839392, 845498, 852069, 852799, 867535, 894908, 898516,
28
+ ]
29
+
30
+
31
+
32
+ def structural_checks(path: str) -> TinyLunarPolicy:
33
+ m = TinyLunarPolicy.from_safetensors(path)
34
+ W = m.weights()
35
+ print(f"params : {m.n_params} (expected 32)")
36
+ print(f"weights shape (obs,act): {W.shape} (expected (8, 4))")
37
+ assert m.n_params == 32, m.n_params
38
+
39
+ rng = np.random.default_rng(0)
40
+ O = rng.standard_normal((2000, OBS_DIM)).astype(np.float32)
41
+ ref = np.argmax(O @ W, axis=1) # obs @ W -> argmax
42
+ got = np.asarray([m.act(o) for o in O[:500]]) # module argmax
43
+ assert (ref[:500] == got).all(), "module disagrees with obs @ W argmax"
44
+ print(f"argmax(obs @ W) == module.act : {int((ref[:500] == got).sum())}/500 OK")
45
+ return m
46
+
47
+
48
+ def live_eval(m: TinyLunarPolicy, seeds, n_ep: int, env_id: str = "LunarLander-v3"):
49
+ import gymnasium as gym
50
+ env = gym.make(env_id)
51
+ means, lands, crashes = [], 0, 0
52
+ for s in seeds:
53
+ rs = []
54
+ for ep in range(n_ep):
55
+ o, _ = env.reset(seed=int(s) * 1000 + ep)
56
+ done, tot = False, 0.0
57
+ while not done:
58
+ o, r, term, trunc, info = env.step(m.act(o))
59
+ tot += r
60
+ done = term or trunc
61
+ rs.append(tot)
62
+ lands += int(tot > 100) # heuristic: landed (LunarLander landing ~ +100..300)
63
+ crashes += int(tot < -100)
64
+ means.append(float(np.mean(rs)))
65
+ means = np.asarray(means)
66
+ print(f"seeds={len(seeds)} x {n_ep} ep = {len(seeds) * n_ep} episodes")
67
+ print(f"mean={means.mean():.3f} std={means.std():.3f} "
68
+ f"min={means.min():.2f} max={means.max():.2f}")
69
+ print(f"seeds with mean>=200: {int((means >= 200).sum())}/{len(means)}")
70
+ print(f"landing rate~{lands / (len(seeds) * n_ep):.4f} crash rate~{crashes / (len(seeds) * n_ep):.4f}")
71
+ return means
72
+
73
+
74
+ def main():
75
+ ap = argparse.ArgumentParser()
76
+ ap.add_argument("--weights", default=os.path.join(os.path.dirname(__file__), "model.safetensors"))
77
+ ap.add_argument("--episodes", type=int, default=0, help="episodes per seed (0 = skip live eval)")
78
+ ap.add_argument("--seeds", type=int, default=60, help="number of seeds to evaluate")
79
+ ap.add_argument("--env-id", default="LunarLander-v3")
80
+ a = ap.parse_args()
81
+
82
+ m = structural_checks(a.weights)
83
+ if a.episodes > 0:
84
+ live_eval(m, EVAL_SEEDS[: a.seeds], a.episodes, a.env_id)
85
+
86
+
87
+ if __name__ == "__main__":
88
+ main()