Spaces:
Runtime error
Runtime error
fix: ui
Browse files- README.md +32 -0
- checkpoints/.gitkeep +0 -0
- checkpoints/channel_optimizer.pt +0 -0
- requirements.txt +3 -0
- rl/__init__.py +0 -0
- rl/env_adapter.py +138 -0
- rl/infer.py +139 -0
- rl/policy.py +121 -0
- rl/train.py +244 -0
- server/app.py +41 -3
- ui/__init__.py +0 -0
- ui/dashboard.py +366 -0
README.md
CHANGED
|
@@ -15,6 +15,8 @@ tags:
|
|
| 15 |
|
| 16 |
An RL environment that simulates **Go-To-Market (GTM) strategy optimization** for product launches. Agents learn to allocate marketing budgets, target customer segments, craft messaging, run experiments, and adjust pricing to maximize revenue under uncertainty.
|
| 17 |
|
|
|
|
|
|
|
| 18 |
## Why GTM?
|
| 19 |
|
| 20 |
Every startup and growth team does GTM optimization manually β iterating on channels, messaging, and targeting through trial and error. This environment captures the real complexity: noisy metrics, delayed brand effects, diminishing returns on ad spend, and the tension between short-term revenue and long-term brand strength.
|
|
@@ -96,6 +98,35 @@ export OPENAI_API_KEY=sk-...
|
|
| 96 |
python baseline.py --model gpt-4o-mini
|
| 97 |
```
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
### API Endpoints
|
| 100 |
|
| 101 |
| Endpoint | Method | Description |
|
|
@@ -103,6 +134,7 @@ python baseline.py --model gpt-4o-mini
|
|
| 103 |
| `/tasks` | GET | List all tasks with action schemas |
|
| 104 |
| `/baseline` | POST | Run heuristic baseline, return scores |
|
| 105 |
| `/grader` | POST | Get grader score for a task |
|
|
|
|
| 106 |
| `/reset` | POST | Reset environment for a task |
|
| 107 |
| `/step` | POST | Execute one action step |
|
| 108 |
| `/state` | GET | Get current episode state |
|
|
|
|
| 15 |
|
| 16 |
An RL environment that simulates **Go-To-Market (GTM) strategy optimization** for product launches. Agents learn to allocate marketing budgets, target customer segments, craft messaging, run experiments, and adjust pricing to maximize revenue under uncertainty.
|
| 17 |
|
| 18 |
+
The Hugging Face Space ships with an interactive dashboard at `/web/` (the default route) that runs the **trained PPO policy**, an **equal-allocation heuristic**, and a **uniform random** agent on the same task and seed, then renders revenue / brand-health / budget-allocation comparisons via Plotly.
|
| 19 |
+
|
| 20 |
## Why GTM?
|
| 21 |
|
| 22 |
Every startup and growth team does GTM optimization manually β iterating on channels, messaging, and targeting through trial and error. This environment captures the real complexity: noisy metrics, delayed brand effects, diminishing returns on ad spend, and the tension between short-term revenue and long-term brand strength.
|
|
|
|
| 98 |
python baseline.py --model gpt-4o-mini
|
| 99 |
```
|
| 100 |
|
| 101 |
+
### Train an RL Policy
|
| 102 |
+
|
| 103 |
+
A custom lightweight PPO trainer (`rl/train.py`) trains a small actor-critic
|
| 104 |
+
network against the simulator. One checkpoint per task.
|
| 105 |
+
|
| 106 |
+
```bash
|
| 107 |
+
# Train
|
| 108 |
+
python -m rl.train --task channel_optimizer --total-steps 200000
|
| 109 |
+
python -m rl.train --task growth_strategist --total-steps 300000
|
| 110 |
+
python -m rl.train --task market_dominator --total-steps 500000
|
| 111 |
+
|
| 112 |
+
# Inference (greedy rollout, prints per-week actions and grader score)
|
| 113 |
+
python -m rl.infer --task channel_optimizer
|
| 114 |
+
```
|
| 115 |
+
|
| 116 |
+
Checkpoints are written to `checkpoints/<task_id>.pt`. Commit them so the
|
| 117 |
+
deployed Space can serve `/infer` without retraining.
|
| 118 |
+
|
| 119 |
+
### Inference via API
|
| 120 |
+
|
| 121 |
+
```bash
|
| 122 |
+
curl -X POST http://localhost:7860/infer \
|
| 123 |
+
-H "Content-Type: application/json" \
|
| 124 |
+
-d '{"task_id": "channel_optimizer", "seed": 42}'
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
Returns a JSON payload with `grader_score`, `total_revenue`, and the full
|
| 128 |
+
weekly action trajectory.
|
| 129 |
+
|
| 130 |
### API Endpoints
|
| 131 |
|
| 132 |
| Endpoint | Method | Description |
|
|
|
|
| 134 |
| `/tasks` | GET | List all tasks with action schemas |
|
| 135 |
| `/baseline` | POST | Run heuristic baseline, return scores |
|
| 136 |
| `/grader` | POST | Get grader score for a task |
|
| 137 |
+
| `/infer` | POST | Run trained RL policy on a task, return action trajectory |
|
| 138 |
| `/reset` | POST | Reset environment for a task |
|
| 139 |
| `/step` | POST | Execute one action step |
|
| 140 |
| `/state` | GET | Get current episode state |
|
checkpoints/.gitkeep
ADDED
|
File without changes
|
checkpoints/channel_optimizer.pt
ADDED
|
Binary file (94 kB). View file
|
|
|
requirements.txt
CHANGED
|
@@ -5,3 +5,6 @@ pydantic>=2.0.0
|
|
| 5 |
websockets>=15.0.1
|
| 6 |
openai>=1.0.0
|
| 7 |
numpy>=1.24.0
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
websockets>=15.0.1
|
| 6 |
openai>=1.0.0
|
| 7 |
numpy>=1.24.0
|
| 8 |
+
torch>=2.0.0
|
| 9 |
+
plotly>=5.0
|
| 10 |
+
pandas>=2.0
|
rl/__init__.py
ADDED
|
File without changes
|
rl/env_adapter.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bridge between GTMEnvironment (dict-based) and tensor-based RL code."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Dict, List
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
|
| 9 |
+
from models import GTMAction, GTMObservation
|
| 10 |
+
from server.simulation import MESSAGING_DIMS
|
| 11 |
+
from server.tasks import TaskDefinition
|
| 12 |
+
|
| 13 |
+
# Sentinel string used by the categorical heads when the agent picks "no action"
|
| 14 |
+
NONE_OPTION = "__none__"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def compute_obs_dim(task: TaskDefinition) -> int:
|
| 18 |
+
"""Number of scalar features in the flattened observation tensor."""
|
| 19 |
+
n_channels = len(task.channels)
|
| 20 |
+
n_segments = len(task.segments)
|
| 21 |
+
# 5 globals + 5 per channel + 4 per segment
|
| 22 |
+
return 5 + 5 * n_channels + 4 * n_segments
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def compute_action_dims(task: TaskDefinition) -> Dict[str, int]:
|
| 26 |
+
"""Output sizes for each policy head."""
|
| 27 |
+
return {
|
| 28 |
+
"budget": len(task.channels),
|
| 29 |
+
"segment": len(task.segments),
|
| 30 |
+
"messaging": len(MESSAGING_DIMS),
|
| 31 |
+
# +1 for the "none" option
|
| 32 |
+
"experiment": len(task.available_experiments) + 1,
|
| 33 |
+
"pricing": len(task.available_pricing_actions) + 1,
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def experiment_options(task: TaskDefinition) -> List[str]:
|
| 38 |
+
return [NONE_OPTION] + list(task.available_experiments)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def pricing_options(task: TaskDefinition) -> List[str]:
|
| 42 |
+
return [NONE_OPTION] + list(task.available_pricing_actions)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def obs_to_tensor(obs: GTMObservation, task: TaskDefinition) -> torch.Tensor:
|
| 46 |
+
"""Flatten a GTMObservation into a fixed-size float32 tensor.
|
| 47 |
+
|
| 48 |
+
Layout (in order):
|
| 49 |
+
- week / total_weeks
|
| 50 |
+
- budget_remaining / total_budget
|
| 51 |
+
- brand_score / 100
|
| 52 |
+
- total_revenue / revenue_target
|
| 53 |
+
- average_cac / 100
|
| 54 |
+
- per channel (in task order): spend/weekly_budget, ctr, cvr, roi, conversions/100
|
| 55 |
+
- per segment (in task order): conversion_rate, engagement/100, churn, revenue/10k
|
| 56 |
+
"""
|
| 57 |
+
total_weeks = max(obs.total_weeks, 1)
|
| 58 |
+
total_budget = task.total_budget if task.total_budget > 0 else 1.0
|
| 59 |
+
weekly_budget = max(obs.weekly_budget, 1.0)
|
| 60 |
+
revenue_target = task.revenue_target if task.revenue_target > 0 else 1.0
|
| 61 |
+
|
| 62 |
+
feats: List[float] = [
|
| 63 |
+
obs.week / total_weeks,
|
| 64 |
+
obs.budget_remaining / total_budget,
|
| 65 |
+
obs.brand_score / 100.0,
|
| 66 |
+
obs.total_revenue / revenue_target,
|
| 67 |
+
obs.average_cac / 100.0,
|
| 68 |
+
]
|
| 69 |
+
|
| 70 |
+
for ch in task.channels:
|
| 71 |
+
m = obs.channel_metrics.get(ch.name)
|
| 72 |
+
if m is None:
|
| 73 |
+
feats.extend([0.0, 0.0, 0.0, 0.0, 0.0])
|
| 74 |
+
else:
|
| 75 |
+
feats.extend([
|
| 76 |
+
m.spend / weekly_budget,
|
| 77 |
+
m.ctr,
|
| 78 |
+
m.cvr,
|
| 79 |
+
# clip ROI into a reasonable range
|
| 80 |
+
max(-2.0, min(5.0, m.roi)),
|
| 81 |
+
m.conversions / 100.0,
|
| 82 |
+
])
|
| 83 |
+
|
| 84 |
+
for seg in task.segments:
|
| 85 |
+
sm = obs.segment_performance.get(seg.name)
|
| 86 |
+
if sm is None:
|
| 87 |
+
feats.extend([0.0, 0.0, 0.0, 0.0])
|
| 88 |
+
else:
|
| 89 |
+
feats.extend([
|
| 90 |
+
sm.conversion_rate,
|
| 91 |
+
min(1.0, sm.engagement_score / 100.0),
|
| 92 |
+
sm.churn_rate,
|
| 93 |
+
sm.revenue / 10000.0,
|
| 94 |
+
])
|
| 95 |
+
|
| 96 |
+
return torch.tensor(feats, dtype=torch.float32)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def policy_sample_to_action(
|
| 100 |
+
sample: Dict[str, torch.Tensor],
|
| 101 |
+
task: TaskDefinition,
|
| 102 |
+
) -> GTMAction:
|
| 103 |
+
"""Convert sampled policy outputs into a GTMAction.
|
| 104 |
+
|
| 105 |
+
sample keys:
|
| 106 |
+
budget β Tensor[n_channels] on the simplex (Dirichlet sample)
|
| 107 |
+
segment β Tensor[n_segments] on the simplex
|
| 108 |
+
messagingβ Tensor[6] on the simplex
|
| 109 |
+
experiment β int (index into experiment_options(task))
|
| 110 |
+
pricing β int (index into pricing_options(task))
|
| 111 |
+
"""
|
| 112 |
+
budget = sample["budget"].detach().cpu().tolist()
|
| 113 |
+
segment = sample["segment"].detach().cpu().tolist()
|
| 114 |
+
messaging = sample["messaging"].detach().cpu().tolist()
|
| 115 |
+
|
| 116 |
+
budget_alloc = {ch.name: float(budget[i]) for i, ch in enumerate(task.channels)}
|
| 117 |
+
segment_target = {seg.name: float(segment[i]) for i, seg in enumerate(task.segments)}
|
| 118 |
+
messaging_dict = {dim: float(messaging[i]) for i, dim in enumerate(MESSAGING_DIMS)}
|
| 119 |
+
|
| 120 |
+
exp_idx = int(sample["experiment"].item())
|
| 121 |
+
exp_opts = experiment_options(task)
|
| 122 |
+
experiment = exp_opts[exp_idx] if exp_idx < len(exp_opts) else NONE_OPTION
|
| 123 |
+
if experiment == NONE_OPTION:
|
| 124 |
+
experiment = None
|
| 125 |
+
|
| 126 |
+
price_idx = int(sample["pricing"].item())
|
| 127 |
+
price_opts = pricing_options(task)
|
| 128 |
+
pricing = price_opts[price_idx] if price_idx < len(price_opts) else NONE_OPTION
|
| 129 |
+
if pricing == NONE_OPTION:
|
| 130 |
+
pricing = None
|
| 131 |
+
|
| 132 |
+
return GTMAction(
|
| 133 |
+
budget_allocation=budget_alloc,
|
| 134 |
+
segment_targeting=segment_target,
|
| 135 |
+
messaging=messaging_dict,
|
| 136 |
+
experiment=experiment,
|
| 137 |
+
pricing_action=pricing,
|
| 138 |
+
)
|
rl/infer.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Inference: load a trained checkpoint and run one episode of GTM optimization.
|
| 2 |
+
|
| 3 |
+
Usage:
|
| 4 |
+
python -m rl.infer --task channel_optimizer
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import argparse
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
import sys
|
| 13 |
+
from typing import Any, Dict, List, Optional
|
| 14 |
+
|
| 15 |
+
import torch
|
| 16 |
+
|
| 17 |
+
# Make repo root importable when run as a module
|
| 18 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 19 |
+
|
| 20 |
+
from server.environment import GTMEnvironment
|
| 21 |
+
from server.tasks import get_task
|
| 22 |
+
|
| 23 |
+
from rl.env_adapter import (
|
| 24 |
+
compute_action_dims,
|
| 25 |
+
compute_obs_dim,
|
| 26 |
+
obs_to_tensor,
|
| 27 |
+
policy_sample_to_action,
|
| 28 |
+
)
|
| 29 |
+
from rl.policy import GTMActorCritic
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _build_policy(task_id: str) -> tuple[GTMActorCritic, Any]:
|
| 33 |
+
task = get_task(task_id)
|
| 34 |
+
obs_dim = compute_obs_dim(task)
|
| 35 |
+
action_dims = compute_action_dims(task)
|
| 36 |
+
policy = GTMActorCritic(
|
| 37 |
+
obs_dim=obs_dim,
|
| 38 |
+
n_channels=action_dims["budget"],
|
| 39 |
+
n_segments=action_dims["segment"],
|
| 40 |
+
n_messaging=action_dims["messaging"],
|
| 41 |
+
n_experiments=action_dims["experiment"],
|
| 42 |
+
n_pricing=action_dims["pricing"],
|
| 43 |
+
)
|
| 44 |
+
return policy, task
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def run_inference(
|
| 48 |
+
task_id: str,
|
| 49 |
+
checkpoint_path: Optional[str] = None,
|
| 50 |
+
seed: Optional[int] = None,
|
| 51 |
+
) -> Dict[str, Any]:
|
| 52 |
+
"""Run one deterministic episode and return the action trajectory + metrics."""
|
| 53 |
+
policy, task = _build_policy(task_id)
|
| 54 |
+
|
| 55 |
+
if checkpoint_path is None:
|
| 56 |
+
checkpoint_path = os.path.join("checkpoints", f"{task_id}.pt")
|
| 57 |
+
|
| 58 |
+
loaded = False
|
| 59 |
+
if os.path.exists(checkpoint_path):
|
| 60 |
+
ckpt = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
|
| 61 |
+
policy.load_state_dict(ckpt["model_state"])
|
| 62 |
+
loaded = True
|
| 63 |
+
|
| 64 |
+
policy.eval()
|
| 65 |
+
|
| 66 |
+
env = GTMEnvironment()
|
| 67 |
+
obs = env.reset(task_id=task_id, seed=seed)
|
| 68 |
+
actions: List[Dict[str, Any]] = []
|
| 69 |
+
|
| 70 |
+
while not obs.done:
|
| 71 |
+
obs_t = obs_to_tensor(obs, task)
|
| 72 |
+
with torch.no_grad():
|
| 73 |
+
sample, _, _ = policy.act(obs_t.unsqueeze(0), deterministic=True)
|
| 74 |
+
sample_squeezed = {k: v.squeeze(0) for k, v in sample.items()}
|
| 75 |
+
action = policy_sample_to_action(sample_squeezed, task)
|
| 76 |
+
|
| 77 |
+
next_obs = env.step(action)
|
| 78 |
+
actions.append({
|
| 79 |
+
"week": next_obs.week,
|
| 80 |
+
"budget_allocation": action.budget_allocation,
|
| 81 |
+
"segment_targeting": action.segment_targeting,
|
| 82 |
+
"messaging": action.messaging,
|
| 83 |
+
"experiment": action.experiment,
|
| 84 |
+
"pricing_action": action.pricing_action,
|
| 85 |
+
"weekly_reward": next_obs.reward,
|
| 86 |
+
"total_revenue": next_obs.total_revenue,
|
| 87 |
+
"brand_score": next_obs.brand_score,
|
| 88 |
+
})
|
| 89 |
+
obs = next_obs
|
| 90 |
+
|
| 91 |
+
grader_score = env.get_grader_score(env.state.episode_id)
|
| 92 |
+
msg = (
|
| 93 |
+
f"Trained policy ({checkpoint_path})" if loaded
|
| 94 |
+
else f"Untrained random policy (no checkpoint at {checkpoint_path})"
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
return {
|
| 98 |
+
"task_id": task_id,
|
| 99 |
+
"checkpoint_loaded": loaded,
|
| 100 |
+
"grader_score": grader_score,
|
| 101 |
+
"total_revenue": float(obs.total_revenue),
|
| 102 |
+
"total_conversions": int(obs.total_conversions),
|
| 103 |
+
"average_cac": float(obs.average_cac),
|
| 104 |
+
"brand_score": float(obs.brand_score),
|
| 105 |
+
"actions": actions,
|
| 106 |
+
"message": msg,
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def main() -> None:
|
| 111 |
+
parser = argparse.ArgumentParser()
|
| 112 |
+
parser.add_argument("--task", required=True, choices=["channel_optimizer", "growth_strategist", "market_dominator"])
|
| 113 |
+
parser.add_argument("--checkpoint", default=None)
|
| 114 |
+
parser.add_argument("--seed", type=int, default=None)
|
| 115 |
+
parser.add_argument("--json", action="store_true", help="Print full JSON output")
|
| 116 |
+
args = parser.parse_args()
|
| 117 |
+
|
| 118 |
+
result = run_inference(args.task, checkpoint_path=args.checkpoint, seed=args.seed)
|
| 119 |
+
|
| 120 |
+
if args.json:
|
| 121 |
+
print(json.dumps(result, indent=2, default=str))
|
| 122 |
+
return
|
| 123 |
+
|
| 124 |
+
print(f"Task: {result['task_id']}")
|
| 125 |
+
print(f"Checkpoint loaded: {result['checkpoint_loaded']}")
|
| 126 |
+
print(f"Grader score: {result['grader_score']}")
|
| 127 |
+
print(f"Total revenue: ${result['total_revenue']:,.2f}")
|
| 128 |
+
print(f"Total conversions: {result['total_conversions']}")
|
| 129 |
+
print(f"Average CAC: ${result['average_cac']:.2f}")
|
| 130 |
+
print(f"Brand score: {result['brand_score']:.1f}")
|
| 131 |
+
print()
|
| 132 |
+
print("Weekly actions:")
|
| 133 |
+
for a in result["actions"]:
|
| 134 |
+
budget_str = ", ".join(f"{k}={v:.2f}" for k, v in a["budget_allocation"].items())
|
| 135 |
+
print(f" Week {a['week']:2d}: budget=[{budget_str}] reward={a['weekly_reward']:.3f}")
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
if __name__ == "__main__":
|
| 139 |
+
main()
|
rl/policy.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Actor-critic MLP policy for the GTM environment."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Dict, Tuple
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
import torch.nn as nn
|
| 9 |
+
import torch.nn.functional as F
|
| 10 |
+
from torch.distributions import Categorical, Dirichlet
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class GTMActorCritic(nn.Module):
|
| 14 |
+
"""Small actor-critic network with task-specific action heads.
|
| 15 |
+
|
| 16 |
+
Heads:
|
| 17 |
+
budget β Dirichlet over channels (simplex)
|
| 18 |
+
segment β Dirichlet over segments (simplex)
|
| 19 |
+
messaging β Dirichlet over 6 dimensions (simplex)
|
| 20 |
+
experimentβ Categorical (incl. "none")
|
| 21 |
+
pricing β Categorical (incl. "none")
|
| 22 |
+
|
| 23 |
+
Concentrations for the Dirichlet heads are produced by softplus(linear)+1
|
| 24 |
+
so they are always positive and start near a uniform distribution.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
def __init__(
|
| 28 |
+
self,
|
| 29 |
+
obs_dim: int,
|
| 30 |
+
n_channels: int,
|
| 31 |
+
n_segments: int,
|
| 32 |
+
n_messaging: int,
|
| 33 |
+
n_experiments: int,
|
| 34 |
+
n_pricing: int,
|
| 35 |
+
hidden_dim: int = 128,
|
| 36 |
+
):
|
| 37 |
+
super().__init__()
|
| 38 |
+
self.obs_dim = obs_dim
|
| 39 |
+
self.n_channels = n_channels
|
| 40 |
+
self.n_segments = n_segments
|
| 41 |
+
self.n_messaging = n_messaging
|
| 42 |
+
self.n_experiments = n_experiments
|
| 43 |
+
self.n_pricing = n_pricing
|
| 44 |
+
|
| 45 |
+
self.trunk = nn.Sequential(
|
| 46 |
+
nn.Linear(obs_dim, hidden_dim),
|
| 47 |
+
nn.Tanh(),
|
| 48 |
+
nn.Linear(hidden_dim, hidden_dim),
|
| 49 |
+
nn.Tanh(),
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
self.budget_head = nn.Linear(hidden_dim, n_channels)
|
| 53 |
+
self.segment_head = nn.Linear(hidden_dim, n_segments)
|
| 54 |
+
self.messaging_head = nn.Linear(hidden_dim, n_messaging)
|
| 55 |
+
self.experiment_head = nn.Linear(hidden_dim, n_experiments)
|
| 56 |
+
self.pricing_head = nn.Linear(hidden_dim, n_pricing)
|
| 57 |
+
self.value_head = nn.Linear(hidden_dim, 1)
|
| 58 |
+
|
| 59 |
+
def forward(self, obs: torch.Tensor) -> Tuple[Dict[str, torch.distributions.Distribution], torch.Tensor]:
|
| 60 |
+
h = self.trunk(obs)
|
| 61 |
+
|
| 62 |
+
budget_alpha = F.softplus(self.budget_head(h)) + 1.0
|
| 63 |
+
segment_alpha = F.softplus(self.segment_head(h)) + 1.0
|
| 64 |
+
messaging_alpha = F.softplus(self.messaging_head(h)) + 1.0
|
| 65 |
+
|
| 66 |
+
# Avoid sampling exact zeros which would break Dirichlet log_prob
|
| 67 |
+
budget_alpha = budget_alpha.clamp(min=1e-3)
|
| 68 |
+
segment_alpha = segment_alpha.clamp(min=1e-3)
|
| 69 |
+
messaging_alpha = messaging_alpha.clamp(min=1e-3)
|
| 70 |
+
|
| 71 |
+
dists: Dict[str, torch.distributions.Distribution] = {
|
| 72 |
+
"budget": Dirichlet(budget_alpha),
|
| 73 |
+
"segment": Dirichlet(segment_alpha),
|
| 74 |
+
"messaging": Dirichlet(messaging_alpha),
|
| 75 |
+
"experiment": Categorical(logits=self.experiment_head(h)),
|
| 76 |
+
"pricing": Categorical(logits=self.pricing_head(h)),
|
| 77 |
+
}
|
| 78 |
+
value = self.value_head(h).squeeze(-1)
|
| 79 |
+
return dists, value
|
| 80 |
+
|
| 81 |
+
def act(
|
| 82 |
+
self,
|
| 83 |
+
obs: torch.Tensor,
|
| 84 |
+
deterministic: bool = False,
|
| 85 |
+
) -> Tuple[Dict[str, torch.Tensor], torch.Tensor, torch.Tensor]:
|
| 86 |
+
"""Sample (or pick the mode of) an action from the policy.
|
| 87 |
+
|
| 88 |
+
Returns: (sample_dict, total_log_prob, value)
|
| 89 |
+
"""
|
| 90 |
+
dists, value = self.forward(obs)
|
| 91 |
+
sample: Dict[str, torch.Tensor] = {}
|
| 92 |
+
log_probs = []
|
| 93 |
+
for name, dist in dists.items():
|
| 94 |
+
if deterministic:
|
| 95 |
+
if isinstance(dist, Dirichlet):
|
| 96 |
+
# mean of a Dirichlet
|
| 97 |
+
s = dist.concentration / dist.concentration.sum(dim=-1, keepdim=True)
|
| 98 |
+
else:
|
| 99 |
+
s = dist.probs.argmax(dim=-1)
|
| 100 |
+
else:
|
| 101 |
+
s = dist.sample()
|
| 102 |
+
sample[name] = s
|
| 103 |
+
log_probs.append(dist.log_prob(s))
|
| 104 |
+
total_log_prob = torch.stack(log_probs, dim=0).sum(dim=0)
|
| 105 |
+
return sample, total_log_prob, value
|
| 106 |
+
|
| 107 |
+
def evaluate_actions(
|
| 108 |
+
self,
|
| 109 |
+
obs: torch.Tensor,
|
| 110 |
+
actions: Dict[str, torch.Tensor],
|
| 111 |
+
) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
| 112 |
+
"""Recompute log-probs, entropy, and value for stored (obs, action) pairs."""
|
| 113 |
+
dists, value = self.forward(obs)
|
| 114 |
+
log_probs = []
|
| 115 |
+
entropies = []
|
| 116 |
+
for name, dist in dists.items():
|
| 117 |
+
log_probs.append(dist.log_prob(actions[name]))
|
| 118 |
+
entropies.append(dist.entropy())
|
| 119 |
+
total_log_prob = torch.stack(log_probs, dim=0).sum(dim=0)
|
| 120 |
+
total_entropy = torch.stack(entropies, dim=0).sum(dim=0)
|
| 121 |
+
return total_log_prob, total_entropy, value
|
rl/train.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PPO trainer for the GTM Strategy Optimizer.
|
| 2 |
+
|
| 3 |
+
Usage:
|
| 4 |
+
python -m rl.train --task channel_optimizer --total-steps 200000 --seed 0
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import argparse
|
| 10 |
+
import os
|
| 11 |
+
import sys
|
| 12 |
+
import time
|
| 13 |
+
from dataclasses import dataclass
|
| 14 |
+
from typing import Dict, List
|
| 15 |
+
|
| 16 |
+
import torch
|
| 17 |
+
import torch.nn as nn
|
| 18 |
+
import torch.optim as optim
|
| 19 |
+
|
| 20 |
+
# Make repo root importable when run as a module
|
| 21 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 22 |
+
|
| 23 |
+
from server.environment import GTMEnvironment
|
| 24 |
+
from server.tasks import get_task
|
| 25 |
+
|
| 26 |
+
from rl.env_adapter import (
|
| 27 |
+
compute_action_dims,
|
| 28 |
+
compute_obs_dim,
|
| 29 |
+
obs_to_tensor,
|
| 30 |
+
policy_sample_to_action,
|
| 31 |
+
)
|
| 32 |
+
from rl.policy import GTMActorCritic
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ββ Hyperparameters ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 36 |
+
|
| 37 |
+
LR = 3e-4
|
| 38 |
+
GAMMA = 0.99
|
| 39 |
+
GAE_LAMBDA = 0.95
|
| 40 |
+
CLIP_EPS = 0.2
|
| 41 |
+
VF_COEF = 0.5
|
| 42 |
+
ENT_COEF = 0.01
|
| 43 |
+
N_STEPS = 2048
|
| 44 |
+
N_EPOCHS = 10
|
| 45 |
+
BATCH_SIZE = 64
|
| 46 |
+
MAX_GRAD_NORM = 0.5
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# ββ Rollout buffer βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@dataclass
|
| 53 |
+
class RolloutStep:
|
| 54 |
+
obs: torch.Tensor
|
| 55 |
+
action: Dict[str, torch.Tensor]
|
| 56 |
+
log_prob: torch.Tensor
|
| 57 |
+
reward: float
|
| 58 |
+
value: torch.Tensor
|
| 59 |
+
done: bool
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def compute_gae(
|
| 63 |
+
rollout: List[RolloutStep],
|
| 64 |
+
last_value: torch.Tensor,
|
| 65 |
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
| 66 |
+
"""Generalized Advantage Estimation."""
|
| 67 |
+
advantages = torch.zeros(len(rollout), dtype=torch.float32)
|
| 68 |
+
gae = 0.0
|
| 69 |
+
next_value = last_value.item()
|
| 70 |
+
for t in reversed(range(len(rollout))):
|
| 71 |
+
nonterminal = 0.0 if rollout[t].done else 1.0
|
| 72 |
+
delta = rollout[t].reward + GAMMA * next_value * nonterminal - rollout[t].value.item()
|
| 73 |
+
gae = delta + GAMMA * GAE_LAMBDA * nonterminal * gae
|
| 74 |
+
advantages[t] = gae
|
| 75 |
+
next_value = rollout[t].value.item()
|
| 76 |
+
returns = advantages + torch.tensor([s.value.item() for s in rollout], dtype=torch.float32)
|
| 77 |
+
return advantages, returns
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# ββ Training loop ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def train(
|
| 84 |
+
task_id: str,
|
| 85 |
+
total_steps: int,
|
| 86 |
+
seed: int = 0,
|
| 87 |
+
checkpoint_dir: str = "checkpoints",
|
| 88 |
+
log_every: int = 1,
|
| 89 |
+
) -> None:
|
| 90 |
+
torch.manual_seed(seed)
|
| 91 |
+
|
| 92 |
+
env = GTMEnvironment()
|
| 93 |
+
task = get_task(task_id)
|
| 94 |
+
obs_dim = compute_obs_dim(task)
|
| 95 |
+
action_dims = compute_action_dims(task)
|
| 96 |
+
|
| 97 |
+
policy = GTMActorCritic(
|
| 98 |
+
obs_dim=obs_dim,
|
| 99 |
+
n_channels=action_dims["budget"],
|
| 100 |
+
n_segments=action_dims["segment"],
|
| 101 |
+
n_messaging=action_dims["messaging"],
|
| 102 |
+
n_experiments=action_dims["experiment"],
|
| 103 |
+
n_pricing=action_dims["pricing"],
|
| 104 |
+
)
|
| 105 |
+
optimizer = optim.Adam(policy.parameters(), lr=LR)
|
| 106 |
+
|
| 107 |
+
obs = env.reset(task_id=task_id, seed=seed)
|
| 108 |
+
obs_t = obs_to_tensor(obs, task)
|
| 109 |
+
|
| 110 |
+
global_step = 0
|
| 111 |
+
update_idx = 0
|
| 112 |
+
best_mean_return = -float("inf")
|
| 113 |
+
episode_returns: List[float] = []
|
| 114 |
+
current_return = 0.0
|
| 115 |
+
start_time = time.time()
|
| 116 |
+
|
| 117 |
+
os.makedirs(checkpoint_dir, exist_ok=True)
|
| 118 |
+
checkpoint_path = os.path.join(checkpoint_dir, f"{task_id}.pt")
|
| 119 |
+
|
| 120 |
+
while global_step < total_steps:
|
| 121 |
+
# ββ Collect rollout ββββββββββββββββββββββββββββββββββββββ
|
| 122 |
+
rollout: List[RolloutStep] = []
|
| 123 |
+
for _ in range(N_STEPS):
|
| 124 |
+
with torch.no_grad():
|
| 125 |
+
sample, log_prob, value = policy.act(obs_t.unsqueeze(0))
|
| 126 |
+
sample_squeezed = {k: v.squeeze(0) for k, v in sample.items()}
|
| 127 |
+
|
| 128 |
+
action = policy_sample_to_action(sample_squeezed, task)
|
| 129 |
+
next_obs = env.step(action)
|
| 130 |
+
reward = float(next_obs.reward) if next_obs.reward is not None else 0.0
|
| 131 |
+
done = bool(next_obs.done)
|
| 132 |
+
current_return += reward
|
| 133 |
+
|
| 134 |
+
rollout.append(
|
| 135 |
+
RolloutStep(
|
| 136 |
+
obs=obs_t,
|
| 137 |
+
action=sample_squeezed,
|
| 138 |
+
log_prob=log_prob.squeeze(0).detach(),
|
| 139 |
+
reward=reward,
|
| 140 |
+
value=value.squeeze(0).detach(),
|
| 141 |
+
done=done,
|
| 142 |
+
)
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
global_step += 1
|
| 146 |
+
if done:
|
| 147 |
+
episode_returns.append(current_return)
|
| 148 |
+
current_return = 0.0
|
| 149 |
+
next_obs = env.reset(task_id=task_id, seed=seed + global_step)
|
| 150 |
+
obs_t = obs_to_tensor(next_obs, task)
|
| 151 |
+
|
| 152 |
+
# bootstrap final value
|
| 153 |
+
with torch.no_grad():
|
| 154 |
+
_, _, last_value = policy.act(obs_t.unsqueeze(0))
|
| 155 |
+
last_value = last_value.squeeze(0).detach()
|
| 156 |
+
|
| 157 |
+
advantages, returns = compute_gae(rollout, last_value)
|
| 158 |
+
advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)
|
| 159 |
+
|
| 160 |
+
# Stack rollout into tensors for minibatching
|
| 161 |
+
obs_batch = torch.stack([s.obs for s in rollout])
|
| 162 |
+
old_log_probs = torch.stack([s.log_prob for s in rollout])
|
| 163 |
+
action_batch = {
|
| 164 |
+
k: torch.stack([s.action[k] for s in rollout]) for k in rollout[0].action
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
# ββ PPO update βββββββββββββββββββββββββββββββββββββββββββ
|
| 168 |
+
n = len(rollout)
|
| 169 |
+
indices = list(range(n))
|
| 170 |
+
for _ in range(N_EPOCHS):
|
| 171 |
+
# shuffle
|
| 172 |
+
perm = torch.randperm(n).tolist()
|
| 173 |
+
for start in range(0, n, BATCH_SIZE):
|
| 174 |
+
mb = perm[start : start + BATCH_SIZE]
|
| 175 |
+
mb_obs = obs_batch[mb]
|
| 176 |
+
mb_actions = {k: v[mb] for k, v in action_batch.items()}
|
| 177 |
+
mb_old_lp = old_log_probs[mb]
|
| 178 |
+
mb_adv = advantages[mb]
|
| 179 |
+
mb_ret = returns[mb]
|
| 180 |
+
|
| 181 |
+
new_log_probs, entropy, value_pred = policy.evaluate_actions(mb_obs, mb_actions)
|
| 182 |
+
ratio = torch.exp(new_log_probs - mb_old_lp)
|
| 183 |
+
surr1 = ratio * mb_adv
|
| 184 |
+
surr2 = torch.clamp(ratio, 1.0 - CLIP_EPS, 1.0 + CLIP_EPS) * mb_adv
|
| 185 |
+
policy_loss = -torch.min(surr1, surr2).mean()
|
| 186 |
+
value_loss = 0.5 * (value_pred - mb_ret).pow(2).mean()
|
| 187 |
+
entropy_loss = -entropy.mean()
|
| 188 |
+
|
| 189 |
+
loss = policy_loss + VF_COEF * value_loss + ENT_COEF * entropy_loss
|
| 190 |
+
|
| 191 |
+
optimizer.zero_grad()
|
| 192 |
+
loss.backward()
|
| 193 |
+
nn.utils.clip_grad_norm_(policy.parameters(), MAX_GRAD_NORM)
|
| 194 |
+
optimizer.step()
|
| 195 |
+
|
| 196 |
+
update_idx += 1
|
| 197 |
+
|
| 198 |
+
# ββ Logging + checkpoint βββββββββββββββββββββββββββββββββ
|
| 199 |
+
recent = episode_returns[-20:] if episode_returns else [current_return]
|
| 200 |
+
mean_return = sum(recent) / len(recent)
|
| 201 |
+
elapsed = time.time() - start_time
|
| 202 |
+
if update_idx % log_every == 0:
|
| 203 |
+
print(
|
| 204 |
+
f"[{task_id}] update={update_idx} step={global_step}/{total_steps} "
|
| 205 |
+
f"episodes={len(episode_returns)} mean_return(last20)={mean_return:.3f} "
|
| 206 |
+
f"policy_loss={policy_loss.item():.4f} value_loss={value_loss.item():.4f} "
|
| 207 |
+
f"entropy={entropy.mean().item():.3f} elapsed={elapsed:.0f}s"
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
if mean_return > best_mean_return and len(episode_returns) >= 5:
|
| 211 |
+
best_mean_return = mean_return
|
| 212 |
+
torch.save(
|
| 213 |
+
{
|
| 214 |
+
"model_state": policy.state_dict(),
|
| 215 |
+
"task_id": task_id,
|
| 216 |
+
"obs_dim": obs_dim,
|
| 217 |
+
"action_dims": action_dims,
|
| 218 |
+
"best_mean_return": best_mean_return,
|
| 219 |
+
"step": global_step,
|
| 220 |
+
},
|
| 221 |
+
checkpoint_path,
|
| 222 |
+
)
|
| 223 |
+
print(f" β³ saved checkpoint (mean_return={best_mean_return:.3f}) β {checkpoint_path}")
|
| 224 |
+
|
| 225 |
+
print(f"Done. Best mean return: {best_mean_return:.3f}. Checkpoint: {checkpoint_path}")
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def main() -> None:
|
| 229 |
+
parser = argparse.ArgumentParser()
|
| 230 |
+
parser.add_argument("--task", required=True, choices=["channel_optimizer", "growth_strategist", "market_dominator"])
|
| 231 |
+
parser.add_argument("--total-steps", type=int, default=200000)
|
| 232 |
+
parser.add_argument("--seed", type=int, default=0)
|
| 233 |
+
parser.add_argument("--checkpoint-dir", default="checkpoints")
|
| 234 |
+
args = parser.parse_args()
|
| 235 |
+
train(
|
| 236 |
+
task_id=args.task,
|
| 237 |
+
total_steps=args.total_steps,
|
| 238 |
+
seed=args.seed,
|
| 239 |
+
checkpoint_dir=args.checkpoint_dir,
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
if __name__ == "__main__":
|
| 244 |
+
main()
|
server/app.py
CHANGED
|
@@ -19,9 +19,16 @@ from models import GTMAction, GTMObservation
|
|
| 19 |
from server.environment import GTMEnvironment
|
| 20 |
from server.tasks import TASKS
|
| 21 |
from server.simulation import MESSAGING_DIMS
|
|
|
|
| 22 |
|
| 23 |
-
# Create the core OpenEnv app (with Gradio
|
| 24 |
-
app = create_app(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
# ββ Root endpoint for HF Spaces ββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -32,7 +39,7 @@ def root():
|
|
| 32 |
return {
|
| 33 |
"name": "GTM Strategy Optimizer",
|
| 34 |
"status": "running",
|
| 35 |
-
"endpoints": ["/tasks", "/reset", "/step", "/state", "/baseline", "/grader", "/health", "/docs"],
|
| 36 |
}
|
| 37 |
|
| 38 |
|
|
@@ -187,3 +194,34 @@ def run_baseline() -> BaselineResponse:
|
|
| 187 |
scores=scores,
|
| 188 |
message="Baseline (equal-allocation heuristic) scores for all tasks",
|
| 189 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
from server.environment import GTMEnvironment
|
| 20 |
from server.tasks import TASKS
|
| 21 |
from server.simulation import MESSAGING_DIMS
|
| 22 |
+
from ui.dashboard import build_dashboard
|
| 23 |
|
| 24 |
+
# Create the core OpenEnv app (with custom Gradio dashboard at /web)
|
| 25 |
+
app = create_app(
|
| 26 |
+
GTMEnvironment,
|
| 27 |
+
GTMAction,
|
| 28 |
+
GTMObservation,
|
| 29 |
+
env_name="gtm_strategy_optimizer",
|
| 30 |
+
gradio_builder=build_dashboard,
|
| 31 |
+
)
|
| 32 |
|
| 33 |
|
| 34 |
# ββ Root endpoint for HF Spaces ββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 39 |
return {
|
| 40 |
"name": "GTM Strategy Optimizer",
|
| 41 |
"status": "running",
|
| 42 |
+
"endpoints": ["/tasks", "/reset", "/step", "/state", "/baseline", "/grader", "/infer", "/health", "/docs"],
|
| 43 |
}
|
| 44 |
|
| 45 |
|
|
|
|
| 194 |
scores=scores,
|
| 195 |
message="Baseline (equal-allocation heuristic) scores for all tasks",
|
| 196 |
)
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
# ββ RL inference endpoint ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
class InferRequest(BaseModel):
|
| 203 |
+
task_id: str
|
| 204 |
+
seed: Optional[int] = None
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
class InferResponse(BaseModel):
|
| 208 |
+
task_id: str
|
| 209 |
+
checkpoint_loaded: bool
|
| 210 |
+
grader_score: Optional[float]
|
| 211 |
+
total_revenue: float
|
| 212 |
+
total_conversions: int
|
| 213 |
+
average_cac: float
|
| 214 |
+
brand_score: float
|
| 215 |
+
actions: list[dict]
|
| 216 |
+
message: str
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
@app.post("/infer")
|
| 220 |
+
def run_infer(req: InferRequest) -> InferResponse:
|
| 221 |
+
"""Run a trained RL policy on a task and return the action trajectory."""
|
| 222 |
+
if req.task_id not in TASKS:
|
| 223 |
+
raise HTTPException(status_code=400, detail=f"Unknown task_id: {req.task_id}")
|
| 224 |
+
from rl.infer import run_inference
|
| 225 |
+
|
| 226 |
+
result = run_inference(req.task_id, seed=req.seed)
|
| 227 |
+
return InferResponse(**result)
|
ui/__init__.py
ADDED
|
File without changes
|
ui/dashboard.py
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Custom Gradio dashboard for the GTM Strategy Optimizer.
|
| 2 |
+
|
| 3 |
+
Mounted into the OpenEnv app via `create_app(..., gradio_builder=build_dashboard)`.
|
| 4 |
+
Shows a side-by-side comparison of three strategies: trained RL, equal-allocation
|
| 5 |
+
heuristic, and uniform random β over the same task and seed.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
import random as _random
|
| 12 |
+
import sys
|
| 13 |
+
from typing import Any, Callable, Dict, List, Optional
|
| 14 |
+
|
| 15 |
+
import gradio as gr
|
| 16 |
+
import pandas as pd
|
| 17 |
+
import plotly.graph_objects as go
|
| 18 |
+
|
| 19 |
+
# Make repo root importable when this module is loaded by uvicorn from /app
|
| 20 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 21 |
+
|
| 22 |
+
from models import GTMAction
|
| 23 |
+
from rl.infer import run_inference
|
| 24 |
+
from server.environment import GTMEnvironment
|
| 25 |
+
from server.simulation import MESSAGING_DIMS
|
| 26 |
+
from server.tasks import TASKS, get_task
|
| 27 |
+
|
| 28 |
+
# ββ Color palette ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 29 |
+
|
| 30 |
+
RL_COLOR = "#7c3aed" # purple
|
| 31 |
+
HEUR_COLOR = "#3b82f6" # blue
|
| 32 |
+
RAND_COLOR = "#94a3b8" # slate
|
| 33 |
+
CHANNEL_PALETTE = [
|
| 34 |
+
"#7c3aed", "#3b82f6", "#10b981", "#f59e0b",
|
| 35 |
+
"#ef4444", "#06b6d4", "#ec4899",
|
| 36 |
+
]
|
| 37 |
+
|
| 38 |
+
# ββ Strategy runners βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _equal_action(task) -> GTMAction:
|
| 42 |
+
return GTMAction(
|
| 43 |
+
budget_allocation={c.name: 1.0 / len(task.channels) for c in task.channels},
|
| 44 |
+
segment_targeting={s.name: 1.0 / len(task.segments) for s in task.segments},
|
| 45 |
+
messaging={d: 1.0 / len(MESSAGING_DIMS) for d in MESSAGING_DIMS},
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _random_action(task, rng: _random.Random) -> GTMAction:
|
| 50 |
+
def _simplex(n: int) -> List[float]:
|
| 51 |
+
xs = [rng.random() + 1e-6 for _ in range(n)]
|
| 52 |
+
s = sum(xs)
|
| 53 |
+
return [x / s for x in xs]
|
| 54 |
+
|
| 55 |
+
bs = _simplex(len(task.channels))
|
| 56 |
+
ss = _simplex(len(task.segments))
|
| 57 |
+
ms = _simplex(len(MESSAGING_DIMS))
|
| 58 |
+
return GTMAction(
|
| 59 |
+
budget_allocation={c.name: bs[i] for i, c in enumerate(task.channels)},
|
| 60 |
+
segment_targeting={s.name: ss[i] for i, s in enumerate(task.segments)},
|
| 61 |
+
messaging={d: ms[i] for i, d in enumerate(MESSAGING_DIMS)},
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _run_with_action_fn(task_id: str, action_fn: Callable, seed: int) -> Dict[str, Any]:
|
| 66 |
+
"""Run one episode driven by action_fn(task) -> GTMAction. Returns trajectory."""
|
| 67 |
+
env = GTMEnvironment()
|
| 68 |
+
task = get_task(task_id)
|
| 69 |
+
obs = env.reset(task_id=task_id, seed=seed)
|
| 70 |
+
weeks: List[Dict[str, Any]] = []
|
| 71 |
+
while not obs.done:
|
| 72 |
+
action = action_fn(task)
|
| 73 |
+
obs = env.step(action)
|
| 74 |
+
weeks.append({
|
| 75 |
+
"week": obs.week,
|
| 76 |
+
"total_revenue": float(obs.total_revenue),
|
| 77 |
+
"brand_score": float(obs.brand_score),
|
| 78 |
+
"budget_allocation": dict(action.budget_allocation),
|
| 79 |
+
"reward": float(obs.reward) if obs.reward is not None else 0.0,
|
| 80 |
+
})
|
| 81 |
+
grader_score = env.get_grader_score(env.state.episode_id)
|
| 82 |
+
return {
|
| 83 |
+
"weeks": weeks,
|
| 84 |
+
"grader_score": grader_score,
|
| 85 |
+
"total_revenue": float(obs.total_revenue),
|
| 86 |
+
"total_conversions": int(obs.total_conversions),
|
| 87 |
+
"brand_score": float(obs.brand_score),
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def run_heuristic(task_id: str, seed: int) -> Dict[str, Any]:
|
| 92 |
+
return _run_with_action_fn(task_id, _equal_action, seed)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def run_random(task_id: str, seed: int) -> Dict[str, Any]:
|
| 96 |
+
rng = _random.Random(seed)
|
| 97 |
+
return _run_with_action_fn(task_id, lambda t: _random_action(t, rng), seed)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def run_trained_rl(task_id: str, seed: int) -> Dict[str, Any]:
|
| 101 |
+
"""Wraps rl.infer.run_inference and normalizes its output to the common shape."""
|
| 102 |
+
result = run_inference(task_id, seed=seed)
|
| 103 |
+
weeks = [
|
| 104 |
+
{
|
| 105 |
+
"week": a["week"],
|
| 106 |
+
"total_revenue": float(a["total_revenue"]),
|
| 107 |
+
"brand_score": float(a["brand_score"]),
|
| 108 |
+
"budget_allocation": a["budget_allocation"],
|
| 109 |
+
"reward": float(a["weekly_reward"]) if a["weekly_reward"] is not None else 0.0,
|
| 110 |
+
}
|
| 111 |
+
for a in result["actions"]
|
| 112 |
+
]
|
| 113 |
+
return {
|
| 114 |
+
"weeks": weeks,
|
| 115 |
+
"grader_score": result["grader_score"],
|
| 116 |
+
"total_revenue": float(result["total_revenue"]),
|
| 117 |
+
"total_conversions": int(result["total_conversions"]),
|
| 118 |
+
"brand_score": float(result["brand_score"]),
|
| 119 |
+
"checkpoint_loaded": result["checkpoint_loaded"],
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
# ββ Plot builders ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def build_revenue_plot(rl: Dict, heur: Dict, rand: Dict) -> go.Figure:
|
| 127 |
+
fig = go.Figure()
|
| 128 |
+
for label, data, color, dash in [
|
| 129 |
+
("π€ Trained RL", rl, RL_COLOR, "solid"),
|
| 130 |
+
("π Heuristic", heur, HEUR_COLOR, "dash"),
|
| 131 |
+
("π² Random", rand, RAND_COLOR, "dot"),
|
| 132 |
+
]:
|
| 133 |
+
weeks = data["weeks"]
|
| 134 |
+
fig.add_trace(go.Scatter(
|
| 135 |
+
x=[w["week"] for w in weeks],
|
| 136 |
+
y=[w["total_revenue"] for w in weeks],
|
| 137 |
+
mode="lines+markers",
|
| 138 |
+
name=label,
|
| 139 |
+
line=dict(color=color, width=3, dash=dash),
|
| 140 |
+
marker=dict(size=7),
|
| 141 |
+
hovertemplate=f"<b>{label}</b><br>Week %{{x}}<br>Revenue $%{{y:,.0f}}<extra></extra>",
|
| 142 |
+
))
|
| 143 |
+
fig.update_layout(
|
| 144 |
+
title="Cumulative Revenue Over Weeks",
|
| 145 |
+
xaxis_title="Week",
|
| 146 |
+
yaxis_title="Revenue ($)",
|
| 147 |
+
template="plotly_white",
|
| 148 |
+
height=420,
|
| 149 |
+
hovermode="x unified",
|
| 150 |
+
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
|
| 151 |
+
margin=dict(l=60, r=20, t=80, b=50),
|
| 152 |
+
)
|
| 153 |
+
return fig
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def build_budget_plot(rl: Dict) -> go.Figure:
|
| 157 |
+
weeks_data = rl["weeks"]
|
| 158 |
+
if not weeks_data:
|
| 159 |
+
return go.Figure()
|
| 160 |
+
weeks = [w["week"] for w in weeks_data]
|
| 161 |
+
channels = list(weeks_data[0]["budget_allocation"].keys())
|
| 162 |
+
fig = go.Figure()
|
| 163 |
+
for i, ch in enumerate(channels):
|
| 164 |
+
fig.add_trace(go.Scatter(
|
| 165 |
+
x=weeks,
|
| 166 |
+
y=[w["budget_allocation"].get(ch, 0.0) for w in weeks_data],
|
| 167 |
+
mode="lines",
|
| 168 |
+
name=ch,
|
| 169 |
+
stackgroup="one",
|
| 170 |
+
line=dict(width=0.5, color=CHANNEL_PALETTE[i % len(CHANNEL_PALETTE)]),
|
| 171 |
+
fillcolor=CHANNEL_PALETTE[i % len(CHANNEL_PALETTE)],
|
| 172 |
+
hovertemplate=f"<b>{ch}</b><br>Week %{{x}}<br>%{{y:.0%}}<extra></extra>",
|
| 173 |
+
))
|
| 174 |
+
fig.update_layout(
|
| 175 |
+
title="RL Budget Allocation per Week",
|
| 176 |
+
xaxis_title="Week",
|
| 177 |
+
yaxis_title="Fraction of weekly budget",
|
| 178 |
+
yaxis_tickformat=".0%",
|
| 179 |
+
template="plotly_white",
|
| 180 |
+
height=380,
|
| 181 |
+
hovermode="x unified",
|
| 182 |
+
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
|
| 183 |
+
margin=dict(l=60, r=20, t=80, b=50),
|
| 184 |
+
)
|
| 185 |
+
return fig
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def build_brand_plot(rl: Dict, heur: Dict, rand: Dict) -> go.Figure:
|
| 189 |
+
fig = go.Figure()
|
| 190 |
+
for label, data, color, dash in [
|
| 191 |
+
("π€ Trained RL", rl, RL_COLOR, "solid"),
|
| 192 |
+
("π Heuristic", heur, HEUR_COLOR, "dash"),
|
| 193 |
+
("π² Random", rand, RAND_COLOR, "dot"),
|
| 194 |
+
]:
|
| 195 |
+
weeks = data["weeks"]
|
| 196 |
+
fig.add_trace(go.Scatter(
|
| 197 |
+
x=[w["week"] for w in weeks],
|
| 198 |
+
y=[w["brand_score"] for w in weeks],
|
| 199 |
+
mode="lines+markers",
|
| 200 |
+
name=label,
|
| 201 |
+
line=dict(color=color, width=3, dash=dash),
|
| 202 |
+
marker=dict(size=7),
|
| 203 |
+
hovertemplate=f"<b>{label}</b><br>Week %{{x}}<br>Brand %{{y:.0f}}/100<extra></extra>",
|
| 204 |
+
))
|
| 205 |
+
fig.update_layout(
|
| 206 |
+
title="Brand Health Over Weeks",
|
| 207 |
+
xaxis_title="Week",
|
| 208 |
+
yaxis_title="Brand Score (0-100)",
|
| 209 |
+
template="plotly_white",
|
| 210 |
+
yaxis=dict(range=[0, 100]),
|
| 211 |
+
height=380,
|
| 212 |
+
hovermode="x unified",
|
| 213 |
+
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
|
| 214 |
+
margin=dict(l=60, r=20, t=80, b=50),
|
| 215 |
+
)
|
| 216 |
+
return fig
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def build_action_table(rl: Dict) -> pd.DataFrame:
|
| 220 |
+
rows = []
|
| 221 |
+
for w in rl["weeks"]:
|
| 222 |
+
row: Dict[str, Any] = {"Week": w["week"]}
|
| 223 |
+
for ch, frac in w["budget_allocation"].items():
|
| 224 |
+
row[ch] = round(frac, 3)
|
| 225 |
+
row["Brand"] = round(w["brand_score"], 1)
|
| 226 |
+
row["Reward"] = round(w["reward"], 3)
|
| 227 |
+
rows.append(row)
|
| 228 |
+
return pd.DataFrame(rows)
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
# ββ Score card markdown ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def _score_card(label: str, emoji: str, color_hex: str, result: Dict, is_winner: bool = False) -> str:
|
| 235 |
+
score = result.get("grader_score")
|
| 236 |
+
score_str = f"{score:.3f}" if score is not None else "β"
|
| 237 |
+
crown = " π" if is_winner else ""
|
| 238 |
+
return (
|
| 239 |
+
f"<div style='border-left:6px solid {color_hex};padding:14px 18px;background:#f8fafc;border-radius:8px;'>"
|
| 240 |
+
f"<div style='font-size:14px;color:#64748b;font-weight:600;text-transform:uppercase;letter-spacing:0.05em'>{emoji} {label}{crown}</div>"
|
| 241 |
+
f"<div style='font-size:36px;font-weight:700;color:#0f172a;margin:4px 0'>{score_str}</div>"
|
| 242 |
+
f"<div style='font-size:13px;color:#475569'>"
|
| 243 |
+
f"π΅ ${result['total_revenue']:,.0f} "
|
| 244 |
+
f"π― {result['total_conversions']} convs "
|
| 245 |
+
f"β Brand {result['brand_score']:.0f}/100"
|
| 246 |
+
f"</div></div>"
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
# ββ Main callback ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def run_comparison(task_id: str, seed_value: float):
|
| 254 |
+
seed = int(seed_value) if seed_value is not None else 42
|
| 255 |
+
|
| 256 |
+
rl = run_trained_rl(task_id, seed)
|
| 257 |
+
heur = run_heuristic(task_id, seed)
|
| 258 |
+
rand = run_random(task_id, seed)
|
| 259 |
+
|
| 260 |
+
# Pick a winner (highest grader score) for the crown emoji
|
| 261 |
+
scores = {
|
| 262 |
+
"rl": rl.get("grader_score") or -1,
|
| 263 |
+
"heur": heur.get("grader_score") or -1,
|
| 264 |
+
"rand": rand.get("grader_score") or -1,
|
| 265 |
+
}
|
| 266 |
+
winner = max(scores, key=scores.get)
|
| 267 |
+
|
| 268 |
+
revenue_fig = build_revenue_plot(rl, heur, rand)
|
| 269 |
+
budget_fig = build_budget_plot(rl)
|
| 270 |
+
brand_fig = build_brand_plot(rl, heur, rand)
|
| 271 |
+
action_df = build_action_table(rl)
|
| 272 |
+
|
| 273 |
+
rl_card = _score_card("Trained RL", "π€", RL_COLOR, rl, is_winner=(winner == "rl"))
|
| 274 |
+
heur_card = _score_card("Heuristic", "π", HEUR_COLOR, heur, is_winner=(winner == "heur"))
|
| 275 |
+
rand_card = _score_card("Random", "π²", RAND_COLOR, rand, is_winner=(winner == "rand"))
|
| 276 |
+
|
| 277 |
+
ckpt_warning = ""
|
| 278 |
+
if not rl.get("checkpoint_loaded"):
|
| 279 |
+
ckpt_warning = (
|
| 280 |
+
"<div style='padding:10px 14px;background:#fef3c7;border-left:4px solid #f59e0b;border-radius:6px;color:#78350f;font-size:13px'>"
|
| 281 |
+
"β οΈ No trained checkpoint found for this task β RL is using a random-init policy. "
|
| 282 |
+
"Run <code>python -m rl.train --task " + task_id + "</code> locally and commit "
|
| 283 |
+
"<code>checkpoints/" + task_id + ".pt</code>."
|
| 284 |
+
"</div>"
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
return revenue_fig, budget_fig, brand_fig, action_df, rl_card, heur_card, rand_card, ckpt_warning
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
# ββ Gradio Blocks builder (called by openenv) ββββββββββββββββββββββββββββββ
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def build_dashboard(*_args, **_kwargs) -> gr.Blocks:
|
| 294 |
+
"""Entry point used as `gradio_builder` in `create_app`.
|
| 295 |
+
|
| 296 |
+
The OpenEnv hook passes (web_manager, action_fields, metadata, is_chat_env,
|
| 297 |
+
title, quick_start_md) which we ignore β we build a fully custom layout.
|
| 298 |
+
"""
|
| 299 |
+
with gr.Blocks(title="GTM Strategy Optimizer") as demo:
|
| 300 |
+
gr.Markdown(
|
| 301 |
+
"# π GTM Strategy Optimizer\n"
|
| 302 |
+
"### RL agent vs heuristic vs random β head-to-head on marketing budget allocation\n"
|
| 303 |
+
"A custom PPO policy trained on a Go-To-Market simulator allocates marketing budget "
|
| 304 |
+
"across channels, segments, and messaging dimensions. Pick a task and run all three "
|
| 305 |
+
"strategies on the same seed to see how the trained policy compares."
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
with gr.Row():
|
| 309 |
+
task_dd = gr.Dropdown(
|
| 310 |
+
choices=list(TASKS.keys()),
|
| 311 |
+
value="channel_optimizer",
|
| 312 |
+
label="Task",
|
| 313 |
+
info="Increasing difficulty: more channels, segments, and adversarial dynamics",
|
| 314 |
+
scale=3,
|
| 315 |
+
)
|
| 316 |
+
seed_in = gr.Number(value=42, label="Seed", precision=0, scale=1)
|
| 317 |
+
run_btn = gr.Button("βΆ Run Comparison", variant="primary", scale=1, size="lg")
|
| 318 |
+
|
| 319 |
+
ckpt_warning_html = gr.HTML(value="")
|
| 320 |
+
|
| 321 |
+
with gr.Row():
|
| 322 |
+
rl_card_md = gr.HTML(_score_card("Trained RL", "π€", RL_COLOR, _empty_result()))
|
| 323 |
+
heur_card_md = gr.HTML(_score_card("Heuristic", "π", HEUR_COLOR, _empty_result()))
|
| 324 |
+
rand_card_md = gr.HTML(_score_card("Random", "π²", RAND_COLOR, _empty_result()))
|
| 325 |
+
|
| 326 |
+
revenue_plot = gr.Plot(label="Cumulative Revenue", show_label=False)
|
| 327 |
+
|
| 328 |
+
with gr.Row():
|
| 329 |
+
budget_plot = gr.Plot(label="Budget Allocation (RL)", show_label=False)
|
| 330 |
+
brand_plot = gr.Plot(label="Brand Health", show_label=False)
|
| 331 |
+
|
| 332 |
+
gr.Markdown("### π Per-week actions (Trained RL)")
|
| 333 |
+
action_df = gr.Dataframe(
|
| 334 |
+
label="Weekly action log",
|
| 335 |
+
interactive=False,
|
| 336 |
+
wrap=True,
|
| 337 |
+
)
|
| 338 |
+
|
| 339 |
+
gr.Markdown(
|
| 340 |
+
"---\n"
|
| 341 |
+
"**How it works.** The trained policy is a small PyTorch actor-critic with "
|
| 342 |
+
"Dirichlet heads for budget/segment/messaging simplices and categorical heads for "
|
| 343 |
+
"experiments and pricing actions. Trained with a custom lightweight PPO loop "
|
| 344 |
+
"(`rl/train.py`) against the simulator's per-step reward "
|
| 345 |
+
"(`server/environment.py:_compute_reward`)."
|
| 346 |
+
)
|
| 347 |
+
|
| 348 |
+
run_btn.click(
|
| 349 |
+
fn=run_comparison,
|
| 350 |
+
inputs=[task_dd, seed_in],
|
| 351 |
+
outputs=[
|
| 352 |
+
revenue_plot, budget_plot, brand_plot, action_df,
|
| 353 |
+
rl_card_md, heur_card_md, rand_card_md, ckpt_warning_html,
|
| 354 |
+
],
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
+
return demo
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
def _empty_result() -> Dict[str, Any]:
|
| 361 |
+
return {
|
| 362 |
+
"grader_score": None,
|
| 363 |
+
"total_revenue": 0.0,
|
| 364 |
+
"total_conversions": 0,
|
| 365 |
+
"brand_score": 0.0,
|
| 366 |
+
}
|