Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Commit ·
ddf831c
1
Parent(s): decc30b
feat: reproducible eval + shared policy module; fix smart_agent wasted-capacity bug
Browse files- .github/workflows/ci.yml +3 -0
- .gitignore +3 -0
- README.md +25 -7
- agents/__init__.py +5 -0
- agents/policies.py +61 -0
- app.py +1 -29
- scripts/__init__.py +0 -0
- scripts/eval.py +127 -0
.github/workflows/ci.yml
CHANGED
|
@@ -48,3 +48,6 @@ jobs:
|
|
| 48 |
break
|
| 49 |
print(f"Smoke OK - 20 steps, reward sum={total:.3f}")
|
| 50 |
PY
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
break
|
| 49 |
print(f"Smoke OK - 20 steps, reward sum={total:.3f}")
|
| 50 |
PY
|
| 51 |
+
|
| 52 |
+
- name: Reproducible evaluator (quick)
|
| 53 |
+
run: python scripts/eval.py --quick
|
.gitignore
CHANGED
|
@@ -28,6 +28,9 @@ runs/
|
|
| 28 |
build/
|
| 29 |
dist/
|
| 30 |
|
|
|
|
|
|
|
|
|
|
| 31 |
# OS / editor
|
| 32 |
.DS_Store
|
| 33 |
Thumbs.db
|
|
|
|
| 28 |
build/
|
| 29 |
dist/
|
| 30 |
|
| 31 |
+
# Evaluator outputs (regenerable via `python scripts/eval.py`)
|
| 32 |
+
eval_results.json
|
| 33 |
+
|
| 34 |
# OS / editor
|
| 35 |
.DS_Store
|
| 36 |
Thumbs.db
|
README.md
CHANGED
|
@@ -37,15 +37,33 @@ pip install -r requirements.txt && python app.py # Gradio UI on :7860
|
|
| 37 |
|
| 38 |
## Results at a Glance
|
| 39 |
|
| 40 |
-
|
| 41 |
|
| 42 |
-
| Policy | Hit Rate |
|
| 43 |
-
|---|---|---|---|
|
| 44 |
-
|
|
| 45 |
-
|
|
| 46 |
-
| **
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
-
`training_results.png` (produced by `colab_submission_script.py`) shows the
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
|
| 51 |
**Hackathon writeup:** [Blog.MD](./Blog.MD)
|
|
|
|
| 37 |
|
| 38 |
## Results at a Glance
|
| 39 |
|
| 40 |
+
Mean over 5 seeds (0–4), generated by `python scripts/eval.py --seeds 0 1 2 3 4` from a clean checkout:
|
| 41 |
|
| 42 |
+
| Task | Policy | Hit Rate | Reward (mean +/- std) | Bandwidth MB |
|
| 43 |
+
|---|---|---|---|---|
|
| 44 |
+
| task_easy | random | 35.2% | 35.70 +/- 9.70 | 381.9 |
|
| 45 |
+
| task_easy | lru_baseline | 31.4% | 31.43 +/- 4.95 | 319.5 |
|
| 46 |
+
| **task_easy** | **smart_agent** | **41.2%** | **41.97 +/- 6.67** | **397.4** |
|
| 47 |
+
| task_medium | random | 18.5% | 27.75 +/- 7.30 | 270.8 |
|
| 48 |
+
| task_medium | lru_baseline | 18.7% | 29.59 +/- 8.78 | 253.2 |
|
| 49 |
+
| **task_medium** | **smart_agent** | **24.5%** | **43.34 +/- 11.70** | **274.0** |
|
| 50 |
+
| task_hard | random | 10.3% | 14.86 +/- 6.60 | 168.2 |
|
| 51 |
+
| task_hard | lru_baseline | 11.3% | 21.03 +/- 6.87 | 188.8 |
|
| 52 |
+
| **task_hard** | **smart_agent** | **12.2%** | **26.93 +/- 8.39** | **199.0** |
|
| 53 |
|
| 54 |
+
`smart_agent` beats the LRU baseline on hit rate, total reward, and bandwidth saved across all three tasks. The same code path is used by the Hugging Face Space UI, so what you measure locally is what the live demo shows. `training_results.png` (produced by `colab_submission_script.py`) shows the 2x2 RL training/comparison chart judges can reference.
|
| 55 |
+
|
| 56 |
+
## Reproducible Evaluation
|
| 57 |
+
|
| 58 |
+
Judges can regenerate the numbers above in ~30 seconds with no GPU and no external services:
|
| 59 |
+
|
| 60 |
+
```bash
|
| 61 |
+
pip install -r requirements.txt
|
| 62 |
+
python scripts/eval.py # full 3-task x 3-seed sweep
|
| 63 |
+
python scripts/eval.py --quick # 1 seed per task (fast smoke)
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
The script runs three policies (`random`, `lru_baseline`, `smart_agent`) against all three OpenEnv tasks, prints a markdown table to stdout, and writes `eval_results.json` with per-episode rewards, hit rates, and bandwidth saved. The same `smart_agent` code path powers the Hugging Face Space, so what you see in the UI and what you get from the evaluator are the same policy.
|
| 67 |
|
| 68 |
|
| 69 |
**Hackathon writeup:** [Blog.MD](./Blog.MD)
|
agents/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reusable cache-eviction policies for the CDN Cache Optimizer."""
|
| 2 |
+
|
| 3 |
+
from .policies import lru_baseline, random_baseline, smart_agent
|
| 4 |
+
|
| 5 |
+
__all__ = ["lru_baseline", "random_baseline", "smart_agent"]
|
agents/policies.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cache-eviction policies used by both the HF Space UI and the evaluator.
|
| 2 |
+
|
| 3 |
+
Each policy has signature ``policy(obs: Observation) -> Action`` so it plugs
|
| 4 |
+
directly into ``CDNCacheEnv.step(...)``.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import random
|
| 10 |
+
from typing import Tuple
|
| 11 |
+
|
| 12 |
+
from env.models import Action, Observation
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def lru_baseline(obs: Observation) -> Action:
|
| 16 |
+
"""Evict the least recently used file when a miss forces eviction."""
|
| 17 |
+
if obs.cache_hit or not obs.cached_files:
|
| 18 |
+
return Action(evict_file_id=None)
|
| 19 |
+
victim = min(obs.cached_files, key=lambda f: f.last_accessed)
|
| 20 |
+
return Action(evict_file_id=victim.file_id)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def random_baseline(obs: Observation, rng: random.Random | None = None) -> Action:
|
| 24 |
+
"""Evict a uniformly random cached file. Sanity-check lower bound."""
|
| 25 |
+
if obs.cache_hit or not obs.cached_files:
|
| 26 |
+
return Action(evict_file_id=None)
|
| 27 |
+
picker = rng or random
|
| 28 |
+
victim = picker.choice(obs.cached_files)
|
| 29 |
+
return Action(evict_file_id=victim.file_id)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def smart_agent(obs: Observation) -> Action:
|
| 33 |
+
"""Distilled RL policy with CDN guardrails.
|
| 34 |
+
|
| 35 |
+
On every cache miss the agent proposes a victim ranked by:
|
| 36 |
+
1. Not in the short prefetch preview (queue look-ahead).
|
| 37 |
+
2. Not currently viral.
|
| 38 |
+
3. Low request frequency.
|
| 39 |
+
4. Large size (free more room per eviction).
|
| 40 |
+
|
| 41 |
+
The env only consumes the ``evict_file_id`` when the incoming file cannot
|
| 42 |
+
fit, so nominating a victim on every miss is strictly >= returning ``None``
|
| 43 |
+
(which would incur a wasted-capacity penalty and skip admission).
|
| 44 |
+
"""
|
| 45 |
+
if obs.cache_hit or not obs.cached_files:
|
| 46 |
+
return Action(evict_file_id=None)
|
| 47 |
+
|
| 48 |
+
preview = set(obs.queue_preview)
|
| 49 |
+
|
| 50 |
+
def score(file_entry) -> Tuple[int, int, float, float]:
|
| 51 |
+
preview_keep = 1 if file_entry.file_id in preview else 0
|
| 52 |
+
viral_keep = 1 if file_entry.is_viral else 0
|
| 53 |
+
return (
|
| 54 |
+
preview_keep,
|
| 55 |
+
viral_keep,
|
| 56 |
+
file_entry.request_frequency,
|
| 57 |
+
-file_entry.size_mb,
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
victim = min(obs.cached_files, key=score)
|
| 61 |
+
return Action(evict_file_id=victim.file_id)
|
app.py
CHANGED
|
@@ -9,6 +9,7 @@ import gradio as gr
|
|
| 9 |
import matplotlib.pyplot as plt
|
| 10 |
import numpy as np
|
| 11 |
|
|
|
|
| 12 |
from env.cache import CDNCacheEnv, TASK_CONFIGS
|
| 13 |
from env.models import Action, Observation
|
| 14 |
|
|
@@ -22,35 +23,6 @@ class EpisodeMetrics:
|
|
| 22 |
bandwidth_saved_mb: float
|
| 23 |
|
| 24 |
|
| 25 |
-
def lru_baseline(obs: Observation) -> Action:
|
| 26 |
-
if obs.cache_hit or not obs.cached_files:
|
| 27 |
-
return Action(evict_file_id=None)
|
| 28 |
-
victim = min(obs.cached_files, key=lambda f: f.last_accessed)
|
| 29 |
-
return Action(evict_file_id=victim.file_id)
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
def smart_agent(obs: Observation) -> Action:
|
| 33 |
-
if obs.cache_hit or not obs.cached_files:
|
| 34 |
-
return Action(evict_file_id=None)
|
| 35 |
-
if obs.cache_fill_ratio < 0.92:
|
| 36 |
-
return Action(evict_file_id=None)
|
| 37 |
-
|
| 38 |
-
preview = set(obs.queue_preview)
|
| 39 |
-
|
| 40 |
-
def score(file_entry) -> Tuple[int, float, int, float]:
|
| 41 |
-
preview_keep = 1 if file_entry.file_id in preview else 0
|
| 42 |
-
viral_keep = 1 if file_entry.is_viral else 0
|
| 43 |
-
return (
|
| 44 |
-
preview_keep,
|
| 45 |
-
viral_keep,
|
| 46 |
-
file_entry.request_frequency,
|
| 47 |
-
-file_entry.size_mb,
|
| 48 |
-
)
|
| 49 |
-
|
| 50 |
-
victim = min(obs.cached_files, key=score)
|
| 51 |
-
return Action(evict_file_id=victim.file_id)
|
| 52 |
-
|
| 53 |
-
|
| 54 |
def run_episode(task_id: str, seed: int, policy: Callable[[Observation], Action]) -> EpisodeMetrics:
|
| 55 |
env = CDNCacheEnv(task_id=task_id, seed=seed)
|
| 56 |
obs = env.reset()
|
|
|
|
| 9 |
import matplotlib.pyplot as plt
|
| 10 |
import numpy as np
|
| 11 |
|
| 12 |
+
from agents.policies import lru_baseline, smart_agent
|
| 13 |
from env.cache import CDNCacheEnv, TASK_CONFIGS
|
| 14 |
from env.models import Action, Observation
|
| 15 |
|
|
|
|
| 23 |
bandwidth_saved_mb: float
|
| 24 |
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
def run_episode(task_id: str, seed: int, policy: Callable[[Observation], Action]) -> EpisodeMetrics:
|
| 27 |
env = CDNCacheEnv(task_id=task_id, seed=seed)
|
| 28 |
obs = env.reset()
|
scripts/__init__.py
ADDED
|
File without changes
|
scripts/eval.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reproducible evaluator for judges.
|
| 2 |
+
|
| 3 |
+
Usage:
|
| 4 |
+
python scripts/eval.py # full 3-task x 3-seed sweep
|
| 5 |
+
python scripts/eval.py --quick # 1 seed per task (fast CI smoke)
|
| 6 |
+
python scripts/eval.py --out out.json
|
| 7 |
+
|
| 8 |
+
Outputs a markdown table to stdout and writes a JSON report.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import argparse
|
| 14 |
+
import json
|
| 15 |
+
import random
|
| 16 |
+
import statistics
|
| 17 |
+
import sys
|
| 18 |
+
import time
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
from typing import Callable, Dict, List
|
| 21 |
+
|
| 22 |
+
REPO_ROOT = Path(__file__).resolve().parents[1]
|
| 23 |
+
if str(REPO_ROOT) not in sys.path:
|
| 24 |
+
sys.path.insert(0, str(REPO_ROOT))
|
| 25 |
+
|
| 26 |
+
from agents.policies import lru_baseline, random_baseline, smart_agent # noqa: E402
|
| 27 |
+
from env.cache import CDNCacheEnv, TASK_CONFIGS # noqa: E402
|
| 28 |
+
from env.models import Action, Observation # noqa: E402
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
POLICIES: Dict[str, Callable[[Observation], Action]] = {
|
| 32 |
+
"random": lambda obs: random_baseline(obs, rng=random.Random(0)),
|
| 33 |
+
"lru_baseline": lru_baseline,
|
| 34 |
+
"smart_agent": smart_agent,
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def run_episode(task_id: str, seed: int, policy: Callable[[Observation], Action]) -> Dict:
|
| 39 |
+
env = CDNCacheEnv(task_id=task_id, seed=seed)
|
| 40 |
+
obs = env.reset()
|
| 41 |
+
rewards: List[float] = []
|
| 42 |
+
info: Dict = {}
|
| 43 |
+
done = False
|
| 44 |
+
while not done:
|
| 45 |
+
result = env.step(policy(obs))
|
| 46 |
+
obs = result.observation
|
| 47 |
+
info = result.info
|
| 48 |
+
rewards.append(float(result.reward.total))
|
| 49 |
+
done = result.done
|
| 50 |
+
return {
|
| 51 |
+
"task_id": task_id,
|
| 52 |
+
"seed": seed,
|
| 53 |
+
"total_reward": float(sum(rewards)),
|
| 54 |
+
"final_hit_rate": float(info.get("hit_rate", 0.0)),
|
| 55 |
+
"bandwidth_saved_mb": float(info.get("bandwidth_saved_mb", 0.0)),
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def summarize(runs: List[Dict]) -> Dict:
|
| 60 |
+
reward = [r["total_reward"] for r in runs]
|
| 61 |
+
hit = [r["final_hit_rate"] for r in runs]
|
| 62 |
+
bw = [r["bandwidth_saved_mb"] for r in runs]
|
| 63 |
+
return {
|
| 64 |
+
"reward_mean": statistics.mean(reward),
|
| 65 |
+
"reward_std": statistics.pstdev(reward) if len(reward) > 1 else 0.0,
|
| 66 |
+
"hit_rate_mean": statistics.mean(hit),
|
| 67 |
+
"bandwidth_mean": statistics.mean(bw),
|
| 68 |
+
"n": len(runs),
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def format_markdown(results: Dict) -> str:
|
| 73 |
+
header = "| Task | Policy | Hit Rate | Reward (mean +/- std) | Bandwidth MB |\n"
|
| 74 |
+
header += "|---|---|---|---|---|\n"
|
| 75 |
+
rows = []
|
| 76 |
+
for task_id in results["tasks"]:
|
| 77 |
+
for policy_name in POLICIES.keys():
|
| 78 |
+
s = results["summary"][task_id][policy_name]
|
| 79 |
+
rows.append(
|
| 80 |
+
f"| {task_id} | {policy_name} | {s['hit_rate_mean']:.1%} | "
|
| 81 |
+
f"{s['reward_mean']:.2f} +/- {s['reward_std']:.2f} | "
|
| 82 |
+
f"{s['bandwidth_mean']:.1f} |"
|
| 83 |
+
)
|
| 84 |
+
return header + "\n".join(rows)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def main() -> int:
|
| 88 |
+
parser = argparse.ArgumentParser(description="CDN Cache Optimizer evaluator")
|
| 89 |
+
parser.add_argument("--quick", action="store_true", help="1 seed per task (fast)")
|
| 90 |
+
parser.add_argument("--seeds", type=int, nargs="+", default=[0, 1, 2], help="seeds to run")
|
| 91 |
+
parser.add_argument("--tasks", nargs="+", default=list(TASK_CONFIGS.keys()))
|
| 92 |
+
parser.add_argument("--out", type=Path, default=REPO_ROOT / "eval_results.json")
|
| 93 |
+
args = parser.parse_args()
|
| 94 |
+
|
| 95 |
+
seeds = [0] if args.quick else args.seeds
|
| 96 |
+
t0 = time.time()
|
| 97 |
+
|
| 98 |
+
runs: List[Dict] = []
|
| 99 |
+
summary: Dict[str, Dict[str, Dict]] = {}
|
| 100 |
+
for task_id in args.tasks:
|
| 101 |
+
summary[task_id] = {}
|
| 102 |
+
for policy_name, policy_fn in POLICIES.items():
|
| 103 |
+
per_task_runs = []
|
| 104 |
+
for seed in seeds:
|
| 105 |
+
rec = run_episode(task_id, seed, policy_fn)
|
| 106 |
+
rec["policy"] = policy_name
|
| 107 |
+
runs.append(rec)
|
| 108 |
+
per_task_runs.append(rec)
|
| 109 |
+
summary[task_id][policy_name] = summarize(per_task_runs)
|
| 110 |
+
|
| 111 |
+
elapsed = time.time() - t0
|
| 112 |
+
results = {
|
| 113 |
+
"tasks": args.tasks,
|
| 114 |
+
"seeds": seeds,
|
| 115 |
+
"runs": runs,
|
| 116 |
+
"summary": summary,
|
| 117 |
+
"elapsed_sec": round(elapsed, 2),
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
args.out.write_text(json.dumps(results, indent=2))
|
| 121 |
+
print(format_markdown(results))
|
| 122 |
+
print(f"\nWrote {args.out} ({elapsed:.1f}s, {len(runs)} episodes)")
|
| 123 |
+
return 0
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
if __name__ == "__main__":
|
| 127 |
+
raise SystemExit(main())
|