Add NN self-improvement pipeline: collect, train, push
Browse filesscripts/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>
- 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()
|