RayMelius Claude Opus 4.6 commited on
Commit
7e43568
Β·
1 Parent(s): adb6e68

Add NN self-improvement pipeline: collect, train, push

Browse files

scripts/nn_selfimprove.py β€” three-step pipeline:
1. collect: polls the live HF Space simulation, records (state, action)
pairs from all agents each tick as labeled training samples
2. train: retrains the SociAgentTransformer on collected + synthetic data,
exports to ONNX
3. push: uploads the improved model to RayMelius/soci-agent-nn on HF Hub

Usage:
python scripts/nn_selfimprove.py collect --minutes 60
python scripts/nn_selfimprove.py train --epochs 20
python scripts/nn_selfimprove.py push
python scripts/nn_selfimprove.py all # full pipeline

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Files changed (1) hide show
  1. scripts/nn_selfimprove.py +750 -0
scripts/nn_selfimprove.py ADDED
@@ -0,0 +1,750 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Soci Agent NN β€” Self-Improvement Pipeline
3
+
4
+ Collects training data from the live simulation, retrains the ONNX model,
5
+ and pushes the improved version back to HuggingFace Hub.
6
+
7
+ Three modes:
8
+ python nn_selfimprove.py collect β€” Watch live sim, collect training samples
9
+ python nn_selfimprove.py train β€” Retrain NN on collected data
10
+ python nn_selfimprove.py push β€” Push improved model to HF Hub
11
+ python nn_selfimprove.py all β€” Do all three in sequence
12
+
13
+ Requires: pip install torch onnx onnxruntime httpx huggingface_hub numpy
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import asyncio
20
+ import json
21
+ import logging
22
+ import math
23
+ import os
24
+ import random
25
+ import sys
26
+ import time
27
+ from collections import Counter
28
+ from dataclasses import dataclass
29
+ from pathlib import Path
30
+ from typing import Optional
31
+
32
+ import httpx
33
+ import numpy as np
34
+
35
+ logging.basicConfig(
36
+ level=logging.INFO,
37
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
38
+ )
39
+ logger = logging.getLogger("nn_selfimprove")
40
+
41
+ # ── Paths ────────────────────────────────────────────────────────────────
42
+
43
+ SCRIPT_DIR = Path(__file__).parent
44
+ PROJECT_DIR = SCRIPT_DIR.parent
45
+ DATA_DIR = PROJECT_DIR / "data" / "nn_training"
46
+ SAMPLES_FILE = DATA_DIR / "collected_samples.jsonl"
47
+ MODEL_DIR = PROJECT_DIR / "models"
48
+ BEST_PT = MODEL_DIR / "soci_agent_best.pt"
49
+ ONNX_PATH = MODEL_DIR / "soci_agent.onnx"
50
+
51
+ # ── Domain constants (must match nn_client.py and notebook) ──────────────
52
+
53
+ ACTION_TYPES = ["move", "work", "eat", "sleep", "talk", "exercise", "shop", "relax", "wander"]
54
+ ACTION_TO_IDX = {a: i for i, a in enumerate(ACTION_TYPES)}
55
+
56
+ LOCATIONS = [
57
+ "house_elena", "house_marcus", "house_helen", "house_diana", "house_kai",
58
+ "house_priya", "house_james", "house_rosa", "house_yuki", "house_frank",
59
+ "apartment_block_1", "apartment_block_2", "apartment_block_3",
60
+ "apt_northeast", "apt_northwest", "apt_southeast", "apt_southwest",
61
+ "cafe", "grocery", "bar", "restaurant", "bakery", "cinema", "diner", "pharmacy",
62
+ "office", "office_tower", "factory", "school", "hospital",
63
+ "park", "gym", "library", "church", "town_square", "sports_field",
64
+ "street_north", "street_south", "street_east", "street_west",
65
+ ]
66
+ LOC_TO_IDX = {loc: i for i, loc in enumerate(LOCATIONS)}
67
+ NUM_LOCATIONS = len(LOCATIONS)
68
+
69
+ NEED_NAMES = ["hunger", "energy", "social", "purpose", "comfort", "fun"]
70
+ ACTION_DURATIONS = {"move": 1, "work": 4, "eat": 2, "sleep": 8, "talk": 2, "exercise": 3, "shop": 2, "relax": 2, "wander": 1}
71
+
72
+ FEATURE_DIM = 47
73
+ NUM_ACTIONS = len(ACTION_TYPES)
74
+
75
+ # ── Feature encoding (same as nn_client.py) ──────────────────────────────
76
+
77
+ def _time_period(hour: int) -> int:
78
+ if hour < 6: return 0
79
+ if hour < 9: return 1
80
+ if hour < 12: return 2
81
+ if hour < 14: return 3
82
+ if hour < 18: return 4
83
+ if hour < 22: return 5
84
+ return 6
85
+
86
+
87
+ def encode_features(
88
+ personality: dict, age: float, hour: int, minute: int, day: int,
89
+ needs: dict, mood: float, current_loc: str,
90
+ home_loc: str = "", work_loc: str = "", num_people: int = 0,
91
+ ) -> list[float]:
92
+ """Encode agent state into 47-dim feature vector."""
93
+ f: list[float] = []
94
+ f.append(personality.get("openness", 5) / 10.0)
95
+ f.append(personality.get("conscientiousness", 5) / 10.0)
96
+ f.append(personality.get("extraversion", 5) / 10.0)
97
+ f.append(personality.get("agreeableness", 5) / 10.0)
98
+ f.append(personality.get("neuroticism", 5) / 10.0)
99
+ f.append(age / 100.0)
100
+ f.append(math.sin(2 * math.pi * hour / 24))
101
+ f.append(math.cos(2 * math.pi * hour / 24))
102
+ f.append(math.sin(2 * math.pi * minute / 60))
103
+ f.append(math.cos(2 * math.pi * minute / 60))
104
+ dow = (day - 1) % 7
105
+ f.append(dow / 7.0)
106
+ f.append(1.0 if dow >= 5 else 0.0)
107
+ for n in NEED_NAMES:
108
+ f.append(needs.get(n, 0.5))
109
+ f.append(max(-1.0, min(1.0, mood)))
110
+ vals = [needs.get(n, 0.5) for n in NEED_NAMES]
111
+ urgent_idx = int(np.argmin(vals))
112
+ f.append(urgent_idx / 5.0)
113
+ f.append(1.0 if any(v < 0.15 for v in vals) else 0.0)
114
+ zone = 0 if current_loc.startswith(("house_", "apartment_", "apt_")) else (
115
+ 1 if current_loc in ("cafe", "grocery", "bar", "restaurant", "bakery", "cinema", "diner", "pharmacy") else (
116
+ 2 if current_loc in ("office", "office_tower", "factory", "school", "hospital") else 3))
117
+ f.append(zone / 3.0)
118
+ f.append(1.0 if current_loc == home_loc else 0.0)
119
+ f.append(1.0 if current_loc == work_loc else 0.0)
120
+ f.append(min(num_people / 10.0, 1.0))
121
+ loc_oh = [0.0] * 6
122
+ if zone == 0: loc_oh[0] = 1.0
123
+ elif zone == 1: loc_oh[1] = 1.0
124
+ elif zone == 2: loc_oh[2] = 1.0
125
+ elif current_loc.startswith("street_"): loc_oh[4] = 1.0
126
+ else: loc_oh[3] = 1.0
127
+ if current_loc == home_loc: loc_oh[5] = 1.0
128
+ f.extend(loc_oh)
129
+ tp = [0.0] * 7
130
+ tp[_time_period(hour)] = 1.0
131
+ f.extend(tp)
132
+ f.extend([0.0] * 9) # last action
133
+ return f
134
+
135
+
136
+ # ════════════════════════════════════════════════════════════════════════
137
+ # STEP 1: COLLECT β€” Watch live sim and record training samples
138
+ # ════════════════════════════════════════════════════════════════════════
139
+
140
+ async def collect(
141
+ base_url: str = "https://raymelius-soci2.hf.space",
142
+ duration_minutes: int = 60,
143
+ poll_interval: float = 3.0,
144
+ ):
145
+ """Poll the live simulation and collect (state, action) training pairs.
146
+
147
+ Each tick, for each agent we observe:
148
+ - Input: agent persona + needs + mood + location + time
149
+ - Label: the action they actually chose (whether from NN, Gemini, or routine)
150
+
151
+ This is teacher-free learning β€” whatever the simulation does IS the label.
152
+ When Gemini makes a decision (10% of the time), it's a high-quality sample.
153
+ """
154
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
155
+
156
+ logger.info(f"Collecting from {base_url} for {duration_minutes} min...")
157
+ logger.info(f"Saving to {SAMPLES_FILE}")
158
+
159
+ # Fetch agent personas (static data)
160
+ async with httpx.AsyncClient(base_url=base_url, timeout=30.0) as client:
161
+ # Get detailed persona info for each agent
162
+ agents_resp = await client.get("/api/agents")
163
+ agents_resp.raise_for_status()
164
+ agents_summary = agents_resp.json()
165
+
166
+ # Build persona cache with personality traits
167
+ persona_cache: dict[str, dict] = {}
168
+ for agent in agents_summary:
169
+ aid = agent["id"]
170
+ try:
171
+ detail_resp = await client.get(f"/api/agents/{aid}")
172
+ if detail_resp.status_code == 200:
173
+ detail = detail_resp.json()
174
+ persona_cache[aid] = {
175
+ "openness": detail.get("persona", {}).get("openness", 5),
176
+ "conscientiousness": detail.get("persona", {}).get("conscientiousness", 5),
177
+ "extraversion": detail.get("persona", {}).get("extraversion", 5),
178
+ "agreeableness": detail.get("persona", {}).get("agreeableness", 5),
179
+ "neuroticism": detail.get("persona", {}).get("neuroticism", 5),
180
+ "age": detail.get("persona", {}).get("age", 30),
181
+ "home": detail.get("persona", {}).get("home_location", ""),
182
+ "work": detail.get("persona", {}).get("work_location", ""),
183
+ }
184
+ except Exception:
185
+ pass
186
+
187
+ logger.info(f"Cached {len(persona_cache)} agent personas")
188
+
189
+ # Poll loop
190
+ samples_collected = 0
191
+ last_tick = -1
192
+ start_time = time.monotonic()
193
+ end_time = start_time + duration_minutes * 60
194
+
195
+ with open(SAMPLES_FILE, "a") as f:
196
+ while time.monotonic() < end_time:
197
+ try:
198
+ # Get current city state
199
+ city_resp = await client.get("/api/city")
200
+ if city_resp.status_code != 200:
201
+ await asyncio.sleep(poll_interval)
202
+ continue
203
+ city = city_resp.json()
204
+
205
+ clock = city.get("clock", {})
206
+ tick = clock.get("total_ticks", 0)
207
+
208
+ # Skip if same tick
209
+ if tick == last_tick:
210
+ await asyncio.sleep(poll_interval)
211
+ continue
212
+ last_tick = tick
213
+
214
+ hour = clock.get("hour", 12)
215
+ minute = clock.get("minute", 0)
216
+ day = clock.get("day", 1)
217
+
218
+ # Count agents per location
219
+ loc_counts: dict[str, int] = {}
220
+ for aid, adata in city.get("agents", {}).items():
221
+ loc = adata.get("location", "")
222
+ loc_counts[loc] = loc_counts.get(loc, 0) + 1
223
+
224
+ # Collect a sample for each agent
225
+ for aid, adata in city.get("agents", {}).items():
226
+ action_str = adata.get("action", "idle")
227
+ state = adata.get("state", "idle")
228
+ location = adata.get("location", "")
229
+ mood = adata.get("mood", 0.0)
230
+ needs = adata.get("needs", {})
231
+
232
+ # Map state to action type
233
+ state_to_action = {
234
+ "idle": "wander", "moving": "move", "working": "work",
235
+ "eating": "eat", "sleeping": "sleep",
236
+ "socializing": "talk", "in_conversation": "talk",
237
+ "exercising": "exercise", "shopping": "shop",
238
+ "relaxing": "relax",
239
+ }
240
+ action_type = state_to_action.get(state, "wander")
241
+
242
+ if action_type not in ACTION_TO_IDX:
243
+ continue
244
+
245
+ persona = persona_cache.get(aid, {
246
+ "openness": 5, "conscientiousness": 5, "extraversion": 5,
247
+ "agreeableness": 5, "neuroticism": 5, "age": 30,
248
+ "home": "", "work": "",
249
+ })
250
+
251
+ features = encode_features(
252
+ personality=persona,
253
+ age=persona.get("age", 30),
254
+ hour=hour, minute=minute, day=day,
255
+ needs=needs, mood=mood,
256
+ current_loc=location,
257
+ home_loc=persona.get("home", ""),
258
+ work_loc=persona.get("work", ""),
259
+ num_people=loc_counts.get(location, 0),
260
+ )
261
+
262
+ sample = {
263
+ "features": features,
264
+ "action_idx": ACTION_TO_IDX[action_type],
265
+ "target_loc_idx": LOC_TO_IDX.get(location, 0),
266
+ "duration": ACTION_DURATIONS.get(action_type, 2),
267
+ "tick": tick,
268
+ "agent_id": aid,
269
+ "source": city.get("llm_provider", "unknown"),
270
+ }
271
+
272
+ f.write(json.dumps(sample) + "\n")
273
+ samples_collected += 1
274
+
275
+ elapsed = (time.monotonic() - start_time) / 60
276
+ logger.info(
277
+ f"Tick {tick} | Day {day} {hour:02d}:{minute:02d} | "
278
+ f"{samples_collected:,} samples | {elapsed:.1f} min"
279
+ )
280
+
281
+ except httpx.HTTPError as e:
282
+ logger.warning(f"HTTP error: {e}")
283
+ except Exception as e:
284
+ logger.error(f"Collection error: {e}", exc_info=True)
285
+
286
+ await asyncio.sleep(poll_interval)
287
+
288
+ logger.info(f"Collection done: {samples_collected:,} samples saved to {SAMPLES_FILE}")
289
+ return samples_collected
290
+
291
+
292
+ # ════════════════════════════════════════════════════════════════════════
293
+ # STEP 2: TRAIN β€” Retrain the NN on collected + synthetic data
294
+ # ════════════════════════════════════════════════════════════════════════
295
+
296
+ def train(epochs: int = 20, batch_size: int = 512, lr: float = 3e-4):
297
+ """Retrain the SociAgentTransformer on collected data.
298
+
299
+ Loads collected samples from the live sim, mixes with synthetic data
300
+ for robustness, and fine-tunes the existing model weights.
301
+ """
302
+ import torch
303
+ import torch.nn as nn
304
+ import torch.nn.functional as F
305
+ from torch.utils.data import Dataset, DataLoader
306
+
307
+ DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
308
+ logger.info(f"Training on {DEVICE}")
309
+
310
+ # ── Load collected data ──────────────────────────────────────────
311
+ collected = []
312
+ if SAMPLES_FILE.exists():
313
+ with open(SAMPLES_FILE) as f:
314
+ for line in f:
315
+ line = line.strip()
316
+ if line:
317
+ collected.append(json.loads(line))
318
+ logger.info(f"Loaded {len(collected):,} collected samples")
319
+ else:
320
+ logger.warning(f"No collected samples at {SAMPLES_FILE}")
321
+
322
+ if len(collected) < 100:
323
+ logger.warning("Too few collected samples β€” generating synthetic data to supplement")
324
+ # Import synthetic generator from the notebook's logic (inline here)
325
+ collected.extend(_generate_synthetic(50_000 - len(collected)))
326
+
327
+ # ── Dataset ──────────────────────────────────────────────────────
328
+ random.shuffle(collected)
329
+ split = int(len(collected) * 0.9)
330
+ train_data = collected[:split]
331
+ val_data = collected[split:]
332
+
333
+ class ActionDataset(Dataset):
334
+ def __init__(self, data):
335
+ self.features = torch.tensor([d["features"] for d in data], dtype=torch.float32)
336
+ self.actions = torch.tensor([d["action_idx"] for d in data], dtype=torch.long)
337
+ self.locations = torch.tensor([d["target_loc_idx"] for d in data], dtype=torch.long)
338
+ self.durations = torch.tensor([d["duration"] for d in data], dtype=torch.float32)
339
+
340
+ def __len__(self):
341
+ return len(self.actions)
342
+
343
+ def __getitem__(self, idx):
344
+ return {
345
+ "features": self.features[idx],
346
+ "action": self.actions[idx],
347
+ "location": self.locations[idx],
348
+ "duration": self.durations[idx],
349
+ }
350
+
351
+ train_ds = ActionDataset(train_data)
352
+ val_ds = ActionDataset(val_data)
353
+ train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True)
354
+ val_loader = DataLoader(val_ds, batch_size=1024, shuffle=False)
355
+ logger.info(f"Train: {len(train_ds):,}, Val: {len(val_ds):,}")
356
+
357
+ # ── Model (same architecture as notebook) ────────────────────────
358
+ # Import model class inline to avoid dependency on notebook
359
+ model = _build_model().to(DEVICE)
360
+
361
+ # Load existing weights if available
362
+ if BEST_PT.exists():
363
+ model.load_state_dict(torch.load(BEST_PT, map_location=DEVICE, weights_only=True))
364
+ logger.info(f"Loaded existing weights from {BEST_PT}")
365
+ else:
366
+ logger.info("Training from scratch (no existing weights)")
367
+
368
+ # ── Training loop ────────────────────────────────────────────────
369
+ # Class weights
370
+ action_counts = torch.zeros(NUM_ACTIONS)
371
+ for d in train_data:
372
+ action_counts[d["action_idx"]] += 1
373
+ action_weights = (1.0 / (action_counts + 1.0))
374
+ action_weights = action_weights / action_weights.sum() * NUM_ACTIONS
375
+ action_weights = action_weights.to(DEVICE)
376
+
377
+ action_loss_fn = nn.CrossEntropyLoss(weight=action_weights)
378
+ location_loss_fn = nn.CrossEntropyLoss()
379
+ duration_loss_fn = nn.MSELoss()
380
+
381
+ optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-4)
382
+ scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs, eta_min=1e-6)
383
+
384
+ best_acc = 0.0
385
+ MODEL_DIR.mkdir(parents=True, exist_ok=True)
386
+
387
+ for epoch in range(epochs):
388
+ model.train()
389
+ total_loss = 0.0
390
+ n = 0
391
+ for batch in train_loader:
392
+ feat = batch["features"].to(DEVICE)
393
+ out = model(feat)
394
+ loss = (
395
+ 1.0 * action_loss_fn(out["action_logits"], batch["action"].to(DEVICE))
396
+ + 0.5 * location_loss_fn(out["location_logits"], batch["location"].to(DEVICE))
397
+ + 0.2 * duration_loss_fn(out["duration"], batch["duration"].to(DEVICE))
398
+ )
399
+ optimizer.zero_grad()
400
+ loss.backward()
401
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
402
+ optimizer.step()
403
+ total_loss += loss.item()
404
+ n += 1
405
+ scheduler.step()
406
+
407
+ # Validate
408
+ model.eval()
409
+ correct = 0
410
+ total = 0
411
+ with torch.no_grad():
412
+ for batch in val_loader:
413
+ feat = batch["features"].to(DEVICE)
414
+ out = model(feat)
415
+ pred = out["action_logits"].argmax(dim=-1)
416
+ correct += (pred == batch["action"].to(DEVICE)).sum().item()
417
+ total += feat.shape[0]
418
+ acc = correct / total if total > 0 else 0
419
+
420
+ if acc > best_acc:
421
+ best_acc = acc
422
+ torch.save(model.state_dict(), str(BEST_PT))
423
+
424
+ if (epoch + 1) % 5 == 0 or epoch == 0:
425
+ logger.info(
426
+ f"Epoch {epoch+1}/{epochs} | "
427
+ f"Loss: {total_loss/n:.4f} | "
428
+ f"Val Acc: {acc:.1%} | "
429
+ f"Best: {best_acc:.1%}"
430
+ )
431
+
432
+ logger.info(f"Training done. Best accuracy: {best_acc:.1%}")
433
+
434
+ # ── Export to ONNX ───────────────────────────────────────────────
435
+ model.load_state_dict(torch.load(str(BEST_PT), map_location="cpu", weights_only=True))
436
+ model.cpu().eval()
437
+
438
+ dummy = torch.randn(1, FEATURE_DIM)
439
+ torch.onnx.export(
440
+ model, dummy, str(ONNX_PATH),
441
+ input_names=["features"],
442
+ output_names=["action_logits", "location_logits", "duration"],
443
+ dynamic_axes={"features": {0: "batch"}},
444
+ opset_version=17,
445
+ dynamo=False,
446
+ )
447
+ logger.info(f"ONNX exported: {ONNX_PATH} ({ONNX_PATH.stat().st_size / 1024:.0f} KB)")
448
+
449
+ return best_acc
450
+
451
+
452
+ # ════════════════════════════════════════════════════════════════════════
453
+ # STEP 3: PUSH β€” Upload improved model to HuggingFace Hub
454
+ # ════════════════════════════════════════════════════════════════════════
455
+
456
+ def push(repo_id: str = "RayMelius/soci-agent-nn"):
457
+ """Push the retrained ONNX model to HuggingFace Hub."""
458
+ from huggingface_hub import HfApi, login
459
+
460
+ token = os.environ.get("HF_TOKEN", "")
461
+ if not token:
462
+ logger.error("HF_TOKEN not set. Export it: export HF_TOKEN=hf_...")
463
+ sys.exit(1)
464
+
465
+ if not ONNX_PATH.exists():
466
+ logger.error(f"No ONNX model at {ONNX_PATH}. Run 'train' first.")
467
+ sys.exit(1)
468
+
469
+ login(token=token)
470
+ api = HfApi()
471
+ api.create_repo(repo_id, exist_ok=True)
472
+
473
+ # Upload ONNX
474
+ api.upload_file(
475
+ path_or_fileobj=str(ONNX_PATH),
476
+ path_in_repo="soci_agent.onnx",
477
+ repo_id=repo_id,
478
+ commit_message="Self-improve: retrained on live sim data",
479
+ )
480
+ logger.info(f"ONNX model pushed to https://huggingface.co/{repo_id}")
481
+
482
+ # Upload PyTorch weights too
483
+ if BEST_PT.exists():
484
+ api.upload_file(
485
+ path_or_fileobj=str(BEST_PT),
486
+ path_in_repo="soci_agent_best.pt",
487
+ repo_id=repo_id,
488
+ commit_message="Self-improve: retrained weights",
489
+ )
490
+ logger.info("PyTorch weights pushed")
491
+
492
+ # Upload training stats
493
+ stats = {
494
+ "samples_file": str(SAMPLES_FILE),
495
+ "num_samples": sum(1 for _ in open(SAMPLES_FILE)) if SAMPLES_FILE.exists() else 0,
496
+ "model_size_kb": ONNX_PATH.stat().st_size / 1024,
497
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
498
+ }
499
+ stats_path = MODEL_DIR / "training_stats.json"
500
+ stats_path.write_text(json.dumps(stats, indent=2))
501
+ api.upload_file(
502
+ path_or_fileobj=str(stats_path),
503
+ path_in_repo="training_stats.json",
504
+ repo_id=repo_id,
505
+ )
506
+
507
+ logger.info("Push complete!")
508
+
509
+
510
+ # ════════════════════════════════════════════════════════════════════════
511
+ # Model architecture (inline to avoid import dependency)
512
+ # ════════════════════════════════════════════════════════════════════════
513
+
514
+ def _build_model():
515
+ """Build SociAgentTransformer β€” same architecture as the training notebook."""
516
+ import torch
517
+ import torch.nn as nn
518
+ import torch.nn.functional as F
519
+
520
+ class FeatureTokenizer(nn.Module):
521
+ GROUPS = [
522
+ ("personality", 0, 6), ("time", 6, 12), ("needs", 12, 21),
523
+ ("location", 21, 31), ("time_period", 31, 38), ("last_action", 38, 47),
524
+ ]
525
+
526
+ def __init__(self, d_model):
527
+ super().__init__()
528
+ self.projections = nn.ModuleList()
529
+ for name, start, end in self.GROUPS:
530
+ self.projections.append(nn.Sequential(
531
+ nn.Linear(end - start, d_model), nn.LayerNorm(d_model), nn.GELU(),
532
+ ))
533
+ self.pos_embed = nn.Parameter(torch.randn(1, len(self.GROUPS), d_model) * 0.02)
534
+
535
+ def forward(self, features):
536
+ tokens = []
537
+ for i, (_, start, end) in enumerate(self.GROUPS):
538
+ tokens.append(self.projections[i](features[:, start:end]))
539
+ tokens = torch.stack(tokens, dim=1)
540
+ return tokens + self.pos_embed
541
+
542
+ class MoEFeedForward(nn.Module):
543
+ def __init__(self, d_model, d_ff, num_experts=4, top_k=2):
544
+ super().__init__()
545
+ self.num_experts = num_experts
546
+ self.top_k = top_k
547
+ self.gate = nn.Linear(d_model, num_experts, bias=False)
548
+ self.experts = nn.ModuleList([
549
+ nn.Sequential(nn.Linear(d_model, d_ff), nn.GELU(), nn.Linear(d_ff, d_model))
550
+ for _ in range(num_experts)
551
+ ])
552
+
553
+ def forward(self, x):
554
+ B, S, D = x.shape
555
+ gate_probs = F.softmax(self.gate(x), dim=-1)
556
+ top_k_probs, top_k_idx = gate_probs.topk(self.top_k, dim=-1)
557
+ top_k_probs = top_k_probs / top_k_probs.sum(dim=-1, keepdim=True)
558
+ output = torch.zeros_like(x)
559
+ for k in range(self.top_k):
560
+ eidx = top_k_idx[:, :, k]
561
+ w = top_k_probs[:, :, k].unsqueeze(-1)
562
+ for e in range(self.num_experts):
563
+ mask = (eidx == e).unsqueeze(-1)
564
+ if mask.any():
565
+ output = output + mask.float() * w * self.experts[e](x)
566
+ return output
567
+
568
+ class TransformerBlock(nn.Module):
569
+ def __init__(self, d_model, nhead, d_ff, num_experts=4, dropout=0.1):
570
+ super().__init__()
571
+ self.attn = nn.MultiheadAttention(d_model, nhead, dropout=dropout, batch_first=True)
572
+ self.norm1 = nn.LayerNorm(d_model)
573
+ self.moe_ff = MoEFeedForward(d_model, d_ff, num_experts)
574
+ self.norm2 = nn.LayerNorm(d_model)
575
+ self.dropout = nn.Dropout(dropout)
576
+
577
+ def forward(self, x):
578
+ attn_out, _ = self.attn(x, x, x)
579
+ x = self.norm1(x + self.dropout(attn_out))
580
+ ff_out = self.moe_ff(x)
581
+ return self.norm2(x + self.dropout(ff_out))
582
+
583
+ class SociAgentTransformer(nn.Module):
584
+ def __init__(self, d_model=128, nhead=8, num_layers=4, d_ff=256, num_experts=4, dropout=0.1):
585
+ super().__init__()
586
+ self.tokenizer = FeatureTokenizer(d_model)
587
+ self.layers = nn.ModuleList([
588
+ TransformerBlock(d_model, nhead, d_ff, num_experts, dropout)
589
+ for _ in range(num_layers)
590
+ ])
591
+ self.cls_query = nn.Parameter(torch.randn(1, 1, d_model) * 0.02)
592
+ self.cls_attn = nn.MultiheadAttention(d_model, nhead, dropout=dropout, batch_first=True)
593
+ self.cls_norm = nn.LayerNorm(d_model)
594
+ self.action_head = nn.Sequential(
595
+ nn.Linear(d_model, d_model), nn.GELU(), nn.Dropout(dropout),
596
+ nn.Linear(d_model, NUM_ACTIONS),
597
+ )
598
+ self.location_head = nn.Sequential(
599
+ nn.Linear(d_model + NUM_ACTIONS, d_model), nn.GELU(), nn.Dropout(dropout),
600
+ nn.Linear(d_model, NUM_LOCATIONS),
601
+ )
602
+ self.duration_head = nn.Sequential(
603
+ nn.Linear(d_model + NUM_ACTIONS, d_model // 2), nn.GELU(),
604
+ nn.Linear(d_model // 2, 1),
605
+ )
606
+
607
+ def forward(self, features):
608
+ tokens = self.tokenizer(features)
609
+ for layer in self.layers:
610
+ tokens = layer(tokens)
611
+ B = features.shape[0]
612
+ cls = self.cls_query.expand(B, -1, -1)
613
+ cls_out, _ = self.cls_attn(cls, tokens, tokens)
614
+ h = self.cls_norm(cls_out.squeeze(1))
615
+ action_logits = self.action_head(h)
616
+ action_probs = F.softmax(action_logits.detach(), dim=-1)
617
+ h_a = torch.cat([h, action_probs], dim=-1)
618
+ location_logits = self.location_head(h_a)
619
+ duration = torch.sigmoid(self.duration_head(h_a)) * 7.0 + 1.0
620
+ return {
621
+ "action_logits": action_logits,
622
+ "location_logits": location_logits,
623
+ "duration": duration.squeeze(-1),
624
+ }
625
+
626
+ return SociAgentTransformer()
627
+
628
+
629
+ # ════════════════════════════════════════════════════════════════════════
630
+ # Synthetic data fallback (when not enough collected samples)
631
+ # ════════════════════════════════════════════════════════════════════════
632
+
633
+ # Inline personas for synthetic generation
634
+ _PERSONAS = [
635
+ {"O": 8, "C": 7, "E": 4, "A": 6, "N": 5, "age": 34, "home": "house_elena", "work": "office"},
636
+ {"O": 10, "C": 3, "E": 6, "A": 7, "N": 7, "age": 33, "home": "house_elena", "work": "library"},
637
+ {"O": 6, "C": 7, "E": 9, "A": 5, "N": 3, "age": 32, "home": "house_marcus", "work": "gym"},
638
+ {"O": 7, "C": 6, "E": 3, "A": 8, "N": 4, "age": 68, "home": "house_helen", "work": "library"},
639
+ {"O": 5, "C": 8, "E": 5, "A": 8, "N": 3, "age": 58, "home": "house_helen", "work": "bakery"},
640
+ {"O": 9, "C": 3, "E": 8, "A": 5, "N": 5, "age": 22, "home": "house_kai", "work": "cafe"},
641
+ {"O": 7, "C": 8, "E": 5, "A": 7, "N": 6, "age": 38, "home": "house_priya", "work": "hospital"},
642
+ {"O": 5, "C": 7, "E": 7, "A": 9, "N": 4, "age": 62, "home": "house_rosa", "work": "restaurant"},
643
+ {"O": 3, "C": 6, "E": 4, "A": 4, "N": 5, "age": 72, "home": "house_frank", "work": "bar"},
644
+ {"O": 6, "C": 8, "E": 3, "A": 7, "N": 5, "age": 35, "home": "house_frank", "work": "school"},
645
+ ]
646
+
647
+
648
+ def _generate_synthetic(n: int) -> list[dict]:
649
+ """Generate synthetic training samples (same logic as notebook)."""
650
+ data = []
651
+ for _ in range(n):
652
+ p = random.choice(_PERSONAS)
653
+ persona = {
654
+ "openness": p["O"], "conscientiousness": p["C"], "extraversion": p["E"],
655
+ "agreeableness": p["A"], "neuroticism": p["N"],
656
+ }
657
+ hour = random.randint(0, 23)
658
+ minute = random.choice([0, 15, 30, 45])
659
+ day = random.randint(1, 30)
660
+ needs = {}
661
+ for nm in NEED_NAMES:
662
+ needs[nm] = round(random.uniform(0.0, 1.0), 2)
663
+ mood = round(random.uniform(-1.0, 1.0), 2)
664
+ loc = random.choice(LOCATIONS)
665
+
666
+ # Simple rule-based label
667
+ urgent = [(nm, needs[nm]) for nm in NEED_NAMES if needs[nm] < 0.15]
668
+ urgent.sort(key=lambda x: x[1])
669
+ action = None
670
+ target = loc
671
+
672
+ if urgent:
673
+ need_name = urgent[0][0]
674
+ if need_name == "hunger":
675
+ action, target = "eat", random.choice(["cafe", "restaurant", "bakery"])
676
+ elif need_name == "energy":
677
+ action, target = "sleep", p["home"]
678
+ elif need_name == "social":
679
+ action, target = "talk", random.choice(["cafe", "bar", "park"])
680
+ elif need_name == "purpose":
681
+ action, target = "work", p["work"]
682
+ elif need_name == "comfort":
683
+ action, target = "relax", p["home"]
684
+ elif need_name == "fun":
685
+ action, target = "relax", random.choice(["park", "cinema"])
686
+
687
+ if action is None:
688
+ period = _time_period(hour)
689
+ if period == 0:
690
+ action, target = "sleep", p["home"]
691
+ elif period in (2, 4):
692
+ action, target = "work", p["work"]
693
+ elif period == 3:
694
+ action, target = "eat", random.choice(["cafe", "restaurant"])
695
+ elif period == 5:
696
+ action = random.choice(["talk", "eat", "relax"])
697
+ target = random.choice(["bar", "restaurant", "park", p["home"]])
698
+ elif period == 6:
699
+ action, target = "sleep", p["home"]
700
+ else:
701
+ action = random.choice(["eat", "exercise", "move"])
702
+ target = random.choice(["cafe", "gym", p["work"]])
703
+
704
+ features = encode_features(
705
+ personality=persona, age=p["age"],
706
+ hour=hour, minute=minute, day=day,
707
+ needs=needs, mood=mood, current_loc=loc,
708
+ home_loc=p["home"], work_loc=p["work"],
709
+ )
710
+
711
+ data.append({
712
+ "features": features,
713
+ "action_idx": ACTION_TO_IDX.get(action, 0),
714
+ "target_loc_idx": LOC_TO_IDX.get(target, 0),
715
+ "duration": ACTION_DURATIONS.get(action, 2),
716
+ })
717
+
718
+ return data
719
+
720
+
721
+ # ════════════════════════════════════════════════════════════════════════
722
+ # CLI
723
+ # ════════════════════════════════════════════════════════════════════════
724
+
725
+ def main():
726
+ parser = argparse.ArgumentParser(description="Soci Agent NN β€” Self-Improvement Pipeline")
727
+ parser.add_argument("mode", choices=["collect", "train", "push", "all"],
728
+ help="collect=watch live sim, train=retrain NN, push=upload to HF, all=full pipeline")
729
+ parser.add_argument("--url", default="https://raymelius-soci2.hf.space",
730
+ help="Live simulation URL (default: HF Space)")
731
+ parser.add_argument("--minutes", type=int, default=60,
732
+ help="Collection duration in minutes (default: 60)")
733
+ parser.add_argument("--epochs", type=int, default=20,
734
+ help="Training epochs (default: 20)")
735
+ parser.add_argument("--repo", default="RayMelius/soci-agent-nn",
736
+ help="HF Hub repo ID")
737
+ args = parser.parse_args()
738
+
739
+ if args.mode in ("collect", "all"):
740
+ asyncio.run(collect(base_url=args.url, duration_minutes=args.minutes))
741
+
742
+ if args.mode in ("train", "all"):
743
+ train(epochs=args.epochs)
744
+
745
+ if args.mode in ("push", "all"):
746
+ push(repo_id=args.repo)
747
+
748
+
749
+ if __name__ == "__main__":
750
+ main()