Spaces:
Sleeping
Sleeping
Commit ·
71dc210
0
Parent(s):
Data-Centric AI RL Environment — OpenEnv Hackathon Submission
Browse files- .gitignore +45 -0
- .spaceignore +39 -0
- Dockerfile +34 -0
- README.md +307 -0
- __init__.py +16 -0
- client.py +64 -0
- eval_data_centric.py +443 -0
- hf_job_train.py +112 -0
- inference.py +104 -0
- logs/.gitkeep +0 -0
- models.py +104 -0
- openenv.yaml +47 -0
- plot_rewards.py +259 -0
- plots/.gitkeep +0 -0
- pyproject.toml +43 -0
- server/__init__.py +11 -0
- server/anti_exploit.py +202 -0
- server/app.py +136 -0
- server/data_centric_environment.py +727 -0
- server/dataset_generator.py +257 -0
- server/grader.py +400 -0
- server/model_evaluator.py +228 -0
- server/requirements.txt +6 -0
- server/specialist_agents.py +654 -0
- sft_generator.py +359 -0
- test_features_smoke.py +80 -0
- tests/__init__.py +0 -0
- tests/conftest.py +2 -0
- tests/test_environment.py +158 -0
- tests/test_grader.py +195 -0
- train_colab.ipynb +498 -0
- train_data_centric.py +689 -0
.gitignore
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*.pyd
|
| 5 |
+
*.pyo
|
| 6 |
+
*.egg-info/
|
| 7 |
+
*.egg
|
| 8 |
+
.eggs/
|
| 9 |
+
|
| 10 |
+
# Virtual environments
|
| 11 |
+
.venv/
|
| 12 |
+
venv/
|
| 13 |
+
env/
|
| 14 |
+
|
| 15 |
+
# Package managers
|
| 16 |
+
uv.lock
|
| 17 |
+
poetry.lock
|
| 18 |
+
|
| 19 |
+
# Test / build artefacts
|
| 20 |
+
.pytest_cache/
|
| 21 |
+
.coverage
|
| 22 |
+
htmlcov/
|
| 23 |
+
dist/
|
| 24 |
+
build/
|
| 25 |
+
|
| 26 |
+
# Training outputs (generated — not committed until training is done)
|
| 27 |
+
sft_data.jsonl
|
| 28 |
+
generations.jsonl
|
| 29 |
+
training_log.json
|
| 30 |
+
sft-checkpoint/
|
| 31 |
+
data-centric-checkpoints/
|
| 32 |
+
data-centric-adapter/
|
| 33 |
+
data-centric-merged/
|
| 34 |
+
|
| 35 |
+
# IDE
|
| 36 |
+
.vscode/
|
| 37 |
+
.idea/
|
| 38 |
+
|
| 39 |
+
# OS
|
| 40 |
+
.DS_Store
|
| 41 |
+
Thumbs.db
|
| 42 |
+
|
| 43 |
+
# Hackathon docs (keep local only)
|
| 44 |
+
*.pdf
|
| 45 |
+
\[External\]*/
|
.spaceignore
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Files excluded from HuggingFace Space Docker build
|
| 2 |
+
# Keep this lean — only block dev/training files the server doesn't need
|
| 3 |
+
|
| 4 |
+
# Python cache
|
| 5 |
+
__pycache__/
|
| 6 |
+
**/__pycache__/
|
| 7 |
+
*.pyc
|
| 8 |
+
*.pyo
|
| 9 |
+
*.pyd
|
| 10 |
+
*.egg-info/
|
| 11 |
+
|
| 12 |
+
# Dev / test tools
|
| 13 |
+
.venv/
|
| 14 |
+
venv/
|
| 15 |
+
.pytest_cache/
|
| 16 |
+
test_features_smoke.py
|
| 17 |
+
uv.lock
|
| 18 |
+
|
| 19 |
+
# Training-only scripts (not needed by the server at runtime)
|
| 20 |
+
train_data_centric.py
|
| 21 |
+
train_colab.ipynb
|
| 22 |
+
eval_data_centric.py
|
| 23 |
+
sft_generator.py
|
| 24 |
+
inference.py
|
| 25 |
+
hf_job_train.py
|
| 26 |
+
plot_rewards.py
|
| 27 |
+
|
| 28 |
+
# Generated training artefacts
|
| 29 |
+
sft_data.jsonl
|
| 30 |
+
generations.jsonl
|
| 31 |
+
training_log.json
|
| 32 |
+
sft-checkpoint/
|
| 33 |
+
data-centric-checkpoints/
|
| 34 |
+
data-centric-adapter/
|
| 35 |
+
data-centric-merged/
|
| 36 |
+
|
| 37 |
+
# Git
|
| 38 |
+
.git/
|
| 39 |
+
.gitignore
|
Dockerfile
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
# System dependencies
|
| 4 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 5 |
+
git \
|
| 6 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 7 |
+
|
| 8 |
+
# Working directory
|
| 9 |
+
WORKDIR /app
|
| 10 |
+
|
| 11 |
+
# Copy project files
|
| 12 |
+
COPY . .
|
| 13 |
+
|
| 14 |
+
# Install Python dependencies
|
| 15 |
+
RUN pip install --no-cache-dir \
|
| 16 |
+
"openenv-core[core]>=0.2.1" \
|
| 17 |
+
"fastapi>=0.115.0" \
|
| 18 |
+
"uvicorn>=0.24.0" \
|
| 19 |
+
"scikit-learn>=1.3.0" \
|
| 20 |
+
"pandas>=2.0.0" \
|
| 21 |
+
"numpy>=1.24.0"
|
| 22 |
+
|
| 23 |
+
# Install the package itself (enables relative imports)
|
| 24 |
+
RUN pip install --no-cache-dir -e .
|
| 25 |
+
|
| 26 |
+
# HF Spaces runs as non-root user 1000
|
| 27 |
+
RUN useradd -m -u 1000 user
|
| 28 |
+
USER user
|
| 29 |
+
|
| 30 |
+
# Expose port that matches app_port in README.md
|
| 31 |
+
EXPOSE 7860
|
| 32 |
+
|
| 33 |
+
# Start the environment server
|
| 34 |
+
CMD ["python", "-m", "uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Data-Centric AI RL Environment
|
| 3 |
+
emoji: 🧠
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
app_port: 7860
|
| 9 |
+
tags:
|
| 10 |
+
- openenv
|
| 11 |
+
- reinforcement-learning
|
| 12 |
+
- data-centric-ai
|
| 13 |
+
- grpo
|
| 14 |
+
- unsloth
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
# 🧠 Data-Centric AI — Multi-Agent RL Environment
|
| 18 |
+
|
| 19 |
+
An [OpenEnv](https://github.com/meta-pytorch/OpenEnv)-compliant reinforcement learning environment that trains an LLM to act as a **data engineering orchestrator** — coordinating 4 specialist sub-agents across multi-step plans to improve ML datasets under budget constraints.
|
| 20 |
+
|
| 21 |
+
> **Core insight:** In traditional ML, practitioners tune models to squeeze out performance. This environment flips that — the model architecture is deliberately **frozen**, forcing the LLM agent to master *data engineering* as its only lever: diagnosing noise, coordinating specialist agents, and strategically transforming the dataset until accuracy surpasses the target. This is [Data-Centric AI](https://datacentricai.org/) — the paradigm Andrew Ng argues matters more than model architecture.
|
| 22 |
+
|
| 23 |
+
> **Live Space:** https://huggingface.co/spaces/Aswini-Kumar/data-centric-env
|
| 24 |
+
|
| 25 |
+
### Key Capabilities
|
| 26 |
+
|
| 27 |
+
| Capability | How it works |
|
| 28 |
+
|---|---|
|
| 29 |
+
| **Multi-Agent Coordination** | LLM orchestrates 4 specialist agents (cleaner, augmenter, balancer, validator) — deciding *who* to call and *when*, modeling each specialist's strengths |
|
| 30 |
+
| **Long-Horizon Planning** | 30-step budget with sparse terminal reward. Agent must plan inspect → query → apply → validate → submit sequences with delayed feedback |
|
| 31 |
+
| **Theory-of-Mind Reasoning** | Agent infers which specialist is best for the current data problem (class imbalance vs. missing values vs. outliers) |
|
| 32 |
+
| **Anti-Exploit Hardening** | 9 security mechanisms (immutable ground truth, golden rows, cooldowns, budget caps) prevent reward hacking |
|
| 33 |
+
| **Curriculum Learning** | Auto-advances from tutorial → easy → medium → hard based on rolling success rate |
|
| 34 |
+
| **Composable Reward System** | 4-component rubric (accuracy + process + preservation + efficiency) using OpenEnv's `Rubric` base class |
|
| 35 |
+
|
| 36 |
+
---
|
| 37 |
+
|
| 38 |
+
## 🎯 What the Agent Does
|
| 39 |
+
|
| 40 |
+
The agent receives a noisy tabular dataset and a fixed classifier. It must orchestrate specialist sub-agents to clean, augment, and balance the data until accuracy hits a target — **without touching the model**.
|
| 41 |
+
|
| 42 |
+
Each episode:
|
| 43 |
+
1. Agent **inspects** the dataset and model
|
| 44 |
+
2. Agent **queries** specialist sub-agents for recommendations
|
| 45 |
+
3. Agent **applies** the best fix (or **undoes** a bad one)
|
| 46 |
+
4. Agent **validates** accuracy improvement
|
| 47 |
+
5. Agent **submits** when target is reached or budget runs out
|
| 48 |
+
|
| 49 |
+
---
|
| 50 |
+
|
| 51 |
+
## 🏗️ Architecture
|
| 52 |
+
|
| 53 |
+
```
|
| 54 |
+
┌─────────────────────────────────────────────────────────────────┐
|
| 55 |
+
│ LLM Agent (Qwen2.5-3B) │
|
| 56 |
+
│ SFT warmup → GRPO live-environment training │
|
| 57 |
+
└─────────────┬───────────────────────────────────┬───────────────┘
|
| 58 |
+
│ text commands │ structured obs
|
| 59 |
+
▼ ▲
|
| 60 |
+
┌─────────────────────────────────────────────────────────────────┐
|
| 61 |
+
│ DataCentricEnvironment (OpenEnv) │
|
| 62 |
+
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
|
| 63 |
+
│ │ Cleaner │ │Augmenter │ │ Balancer │ │ Validator │ │
|
| 64 |
+
│ │ Agent │ │ Agent │ │ Agent │ │ Agent │ │
|
| 65 |
+
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └──────┬───────┘ │
|
| 66 |
+
│ └──────────────┴──────────────┴───────────────┘ │
|
| 67 |
+
│ │ │
|
| 68 |
+
│ ┌────────────▼────────────┐ │
|
| 69 |
+
│ │ Working Copy (mutable) │◄── Snapshot stack (×3) │
|
| 70 |
+
│ └────────────┬────────────┘ for undo support │
|
| 71 |
+
│ │ │
|
| 72 |
+
│ ┌────────────▼────────────┐ │
|
| 73 |
+
│ │ ModelEvaluator (RF) │ │
|
| 74 |
+
│ │ n_est=20 (fast_mode) │ │
|
| 75 |
+
│ └────────────┬────────────┘ │
|
| 76 |
+
│ │ │
|
| 77 |
+
│ ┌────────────▼────────────┐ │
|
| 78 |
+
│ │ Ground Truth (frozen) │ ← never mutated │
|
| 79 |
+
│ └─────────────────────────┘ │
|
| 80 |
+
│ │
|
| 81 |
+
│ ┌───────────────────────────────────────────────────────────┐ │
|
| 82 |
+
│ │ DataCentricRubric (OpenEnv Rubric system) │ │
|
| 83 |
+
│ │ ├── AccuracyRubric — Δ accuracy vs baseline │ │
|
| 84 |
+
│ │ ├── ProcessRubric — workflow pattern scoring │ │
|
| 85 |
+
│ │ ├── PreservationRubric — row preservation incentive │ │
|
| 86 |
+
│ │ └── EfficiencyRubric — accuracy gain / budget used │ │
|
| 87 |
+
│ │ + StepRubric — dense per-apply proxy reward │ │
|
| 88 |
+
│ └───────────────────────────────────────────────────────────┘ │
|
| 89 |
+
│ │
|
| 90 |
+
│ Anti-Exploit: 9 protections (GT immutability, cooldowns, etc.) │
|
| 91 |
+
└─────────────────────────────────────────────────────────────────┘
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
---
|
| 95 |
+
|
| 96 |
+
## 🌍 Environment Design
|
| 97 |
+
|
| 98 |
+
### Action Space
|
| 99 |
+
Single text command — one of:
|
| 100 |
+
|
| 101 |
+
| Command | Effect |
|
| 102 |
+
|---------|--------|
|
| 103 |
+
| `inspect_dataset` | View shape, missing values, class distribution |
|
| 104 |
+
| `inspect_model` | View classifier accuracy, precision, recall, F1 |
|
| 105 |
+
| `query_cleaner` | Get missing-value / outlier fix recommendations |
|
| 106 |
+
| `query_augmenter [class]` | Get data augmentation recommendations |
|
| 107 |
+
| `query_balancer` | Get class rebalancing recommendations |
|
| 108 |
+
| `query_validator` | Check rule violations (costs 2 budget) |
|
| 109 |
+
| `apply <N>` | Apply recommendation number N |
|
| 110 |
+
| `reject <N>` | Reject a recommendation |
|
| 111 |
+
| `undo` | Revert last apply (max 3 levels deep) |
|
| 112 |
+
| `validate` | Retrain classifier and score (cooldown applies) |
|
| 113 |
+
| `submit` | Finalize and score the episode |
|
| 114 |
+
|
| 115 |
+
### Observation Space
|
| 116 |
+
Each step returns a structured observation:
|
| 117 |
+
|
| 118 |
+
```python
|
| 119 |
+
DataCentricObservation(
|
| 120 |
+
response="...", # Specialist agent's text response
|
| 121 |
+
current_accuracy=0.71, # Current classifier accuracy
|
| 122 |
+
baseline_accuracy=0.62, # Accuracy before any changes
|
| 123 |
+
target_accuracy=0.73, # Target to hit
|
| 124 |
+
estimated_quality=0.84, # Dataset quality score (0-1)
|
| 125 |
+
rows_preserved_pct=0.97, # % of original rows still present
|
| 126 |
+
budget_remaining=22, # Steps remaining
|
| 127 |
+
validate_calls_remaining=2, # Free validate calls left
|
| 128 |
+
active_session="cleaner", # Which specialist is active
|
| 129 |
+
done=False,
|
| 130 |
+
)
|
| 131 |
+
```
|
| 132 |
+
|
| 133 |
+
### Reward Function — OpenEnv Rubric System
|
| 134 |
+
|
| 135 |
+
Uses `openenv.core.rubrics.base.Rubric` with composable child rubrics (nn.Module-style auto-registration):
|
| 136 |
+
|
| 137 |
+
| Rubric | Signal | Range |
|
| 138 |
+
|--------|--------|-------|
|
| 139 |
+
| **AccuracyRubric** | Δ accuracy × 2.5 + submit bonus | [-1.0, +1.0] |
|
| 140 |
+
| **ProcessRubric** | Correct query→apply→validate sequencing | [-0.10, +0.05] |
|
| 141 |
+
| **PreservationRubric** | Rows preserved ≥ 90% | [-0.40, +0.05] |
|
| 142 |
+
| **EfficiencyRubric** | Accuracy gain / budget used (submit only) | [-0.05, +0.20] |
|
| 143 |
+
| **StepRubric** | Dense per-apply quality proxy | [-0.30, +0.15] |
|
| 144 |
+
|
| 145 |
+
Total clamped to **[-1.0, 1.0]** by `DataCentricRubric.forward()`.
|
| 146 |
+
|
| 147 |
+
### Anti-Exploit Protections
|
| 148 |
+
9 hardened mechanisms including:
|
| 149 |
+
- Ground truth immutability assertion after every `apply`
|
| 150 |
+
- Validate cooldown enforcement (must take 2 actions between validates)
|
| 151 |
+
- Duplicate apply detection + session apply limit (max 3 per query)
|
| 152 |
+
- Recommendation staleness validation (re-query after each session)
|
| 153 |
+
- Catastrophic data loss detection (< 50% rows → terminate)
|
| 154 |
+
- Episode wall-clock timeout (5 min → forced submit)
|
| 155 |
+
- Input truncation (> 200 chars → truncate + penalty)
|
| 156 |
+
|
| 157 |
+
---
|
| 158 |
+
|
| 159 |
+
## 📚 Task Curriculum (4 Levels)
|
| 160 |
+
|
| 161 |
+
| Task | Rows | Issues | Baseline | Target | Budget |
|
| 162 |
+
|------|------|--------|----------|--------|--------|
|
| 163 |
+
| `task_0_tutorial` | 100 | Missing values (20%) | ~0.62 | 0.73 | 30 |
|
| 164 |
+
| `task_1_easy` | 200 | Missing + imbalance | ~0.63 | 0.79 | 25 |
|
| 165 |
+
| `task_2_medium` | 500 | Missing + duplicates + imbalance + type errors | ~0.58 | 0.74 | 40 |
|
| 166 |
+
| `task_3_hard` | 900 | 6 issues incl. outliers + logic errors | ~0.54 | 0.71 | 60 |
|
| 167 |
+
|
| 168 |
+
Advancement criterion: ≥ 60% success rate over a rolling 30-episode window.
|
| 169 |
+
|
| 170 |
+
---
|
| 171 |
+
|
| 172 |
+
## 🤖 Training Pipeline
|
| 173 |
+
|
| 174 |
+
**Model:** Qwen2.5-3B-Instruct (4-bit via Unsloth)
|
| 175 |
+
**Algorithm:** SFT warmup → GRPO (TRL)
|
| 176 |
+
**Framework:** OpenEnv + TRL + Unsloth
|
| 177 |
+
|
| 178 |
+
### Phase 1 — SFT Warmup
|
| 179 |
+
Train on ~8,100 heuristic trajectory examples to teach valid command syntax before RL.
|
| 180 |
+
|
| 181 |
+
### Phase 2 — GRPO
|
| 182 |
+
Live environment rollouts scored by the composable Rubric system. Curriculum scheduler advances from tutorial → easy → medium → hard as performance improves.
|
| 183 |
+
|
| 184 |
+
### Training Notebook
|
| 185 |
+
[](https://colab.research.google.com/github/CelestialWorthyOfHeavenAndEarth/data-centric-env/blob/main/train_colab.ipynb)
|
| 186 |
+
|
| 187 |
+
[`train_colab.ipynb`](train_colab.ipynb) — complete end-to-end training pipeline for Colab T4 GPU.
|
| 188 |
+
|
| 189 |
+
---
|
| 190 |
+
|
| 191 |
+
## 📊 Results
|
| 192 |
+
|
| 193 |
+
### Heuristic Baseline Verification
|
| 194 |
+
|
| 195 |
+
The heuristic agent (`inference.py`) validates that the environment is solvable:
|
| 196 |
+
|
| 197 |
+
| Task | Accuracy Gain | Target Hit Rate |
|
| 198 |
+
|------|--------------|-----------------|
|
| 199 |
+
| `task_0_tutorial` | +0.11 | ✓ 100% |
|
| 200 |
+
| `task_1_easy` | +0.08 | ✓ 80% |
|
| 201 |
+
| `task_2_medium` | +0.06 | ✓ 60% |
|
| 202 |
+
| `task_3_hard` | +0.04 | ~ 40% |
|
| 203 |
+
|
| 204 |
+
> **Note:** After GRPO training, embed reward curves from `plots/` here.
|
| 205 |
+
> Run `python plot_rewards.py` to generate: `reward_curve.png`, `success_rate.png`, `accuracy_gain.png`, `curriculum.png`.
|
| 206 |
+
|
| 207 |
+
---
|
| 208 |
+
|
| 209 |
+
## 🧪 Testing
|
| 210 |
+
|
| 211 |
+
```bash
|
| 212 |
+
# Run all tests (35 tests — grader + environment)
|
| 213 |
+
pytest tests/ -v
|
| 214 |
+
|
| 215 |
+
# Run only grader tests (22 tests)
|
| 216 |
+
pytest tests/test_grader.py -v
|
| 217 |
+
|
| 218 |
+
# Run only environment safety tests (13 tests)
|
| 219 |
+
pytest tests/test_environment.py -v
|
| 220 |
+
```
|
| 221 |
+
|
| 222 |
+
Tests cover:
|
| 223 |
+
- All 5 Rubric components (accuracy, process, preservation, efficiency, step)
|
| 224 |
+
- Reward clamping to declared [-1.0, 1.0] range
|
| 225 |
+
- Ground truth immutability after every command
|
| 226 |
+
- Budget enforcement and episode termination
|
| 227 |
+
- Validate cooldown and call limiting
|
| 228 |
+
- Undo/snapshot stack behavior
|
| 229 |
+
- Unknown command handling
|
| 230 |
+
|
| 231 |
+
---
|
| 232 |
+
|
| 233 |
+
## 🚀 Quick Start
|
| 234 |
+
|
| 235 |
+
### Connect to the Live Space
|
| 236 |
+
|
| 237 |
+
```python
|
| 238 |
+
from client import DataCentricEnv
|
| 239 |
+
from models import DataCentricAction
|
| 240 |
+
|
| 241 |
+
with DataCentricEnv(base_url="https://aswini-kumar-data-centric-env.hf.space").sync() as env:
|
| 242 |
+
result = env.reset(task="task_0_tutorial", seed=42)
|
| 243 |
+
print(f"Baseline: {result.observation.baseline_accuracy:.2f} Target: {result.observation.target_accuracy:.2f}")
|
| 244 |
+
|
| 245 |
+
result = env.step(DataCentricAction(message="inspect_dataset"))
|
| 246 |
+
print(result.observation.response)
|
| 247 |
+
|
| 248 |
+
result = env.step(DataCentricAction(message="query_cleaner"))
|
| 249 |
+
print(result.observation.response)
|
| 250 |
+
```
|
| 251 |
+
|
| 252 |
+
### Run Locally
|
| 253 |
+
|
| 254 |
+
```bash
|
| 255 |
+
# Install
|
| 256 |
+
pip install openenv-core fastapi uvicorn scikit-learn pandas numpy
|
| 257 |
+
|
| 258 |
+
# Start server
|
| 259 |
+
uvicorn server.app:app --host 0.0.0.0 --port 8000
|
| 260 |
+
|
| 261 |
+
# In another terminal
|
| 262 |
+
python -c "
|
| 263 |
+
from client import DataCentricEnv
|
| 264 |
+
from models import DataCentricAction
|
| 265 |
+
with DataCentricEnv(base_url='http://localhost:8000').sync() as env:
|
| 266 |
+
obs = env.reset(task='task_0_tutorial', seed=42).observation
|
| 267 |
+
print(f'Ready — baseline={obs.baseline_accuracy:.2f} target={obs.target_accuracy:.2f}')
|
| 268 |
+
"
|
| 269 |
+
```
|
| 270 |
+
|
| 271 |
+
---
|
| 272 |
+
|
| 273 |
+
## 📁 Project Structure
|
| 274 |
+
|
| 275 |
+
```
|
| 276 |
+
data_centric_env/
|
| 277 |
+
├── openenv.yaml # OpenEnv manifest (tasks, reward range, action/obs types)
|
| 278 |
+
├── client.py # DataCentricEnv WebSocket client
|
| 279 |
+
├── models.py # DataCentricAction + DataCentricObservation (Pydantic)
|
| 280 |
+
├── train_data_centric.py # Full SFT → GRPO training pipeline
|
| 281 |
+
├── train_colab.ipynb # Colab training notebook (T4 GPU)
|
| 282 |
+
├── eval_data_centric.py # Evaluation: random vs trained agent
|
| 283 |
+
├── plot_rewards.py # Reward curve visualization (4 plots)
|
| 284 |
+
├── sft_generator.py # SFT warmup data generator (~8100 examples)
|
| 285 |
+
├── inference.py # Heuristic baseline agent
|
| 286 |
+
├── tests/
|
| 287 |
+
│ ├── test_grader.py # 22 tests — Rubric system + reward components
|
| 288 |
+
│ └── test_environment.py # 13 tests — safety invariants + anti-exploit
|
| 289 |
+
└── server/
|
| 290 |
+
├── app.py # FastAPI server (HTTP + WebSocket via OpenEnv)
|
| 291 |
+
├── data_centric_environment.py # Core RL environment logic (680 lines)
|
| 292 |
+
├── dataset_generator.py # Synthetic dataset generation (4 task configs)
|
| 293 |
+
├── specialist_agents.py # CleanerAgent, AugmenterAgent, BalancerAgent, ValidatorAgent
|
| 294 |
+
├── grader.py # Composable Rubric system (openenv.core.rubrics.base)
|
| 295 |
+
├── anti_exploit.py # 9 anti-reward-hacking protections
|
| 296 |
+
├── model_evaluator.py # RF classifier with hash-based caching
|
| 297 |
+
└── Dockerfile # HuggingFace Spaces deployment
|
| 298 |
+
```
|
| 299 |
+
|
| 300 |
+
---
|
| 301 |
+
|
| 302 |
+
## 🏷️ Hackathon
|
| 303 |
+
|
| 304 |
+
**Theme:** #3.1 — World Modeling / Professional Tasks
|
| 305 |
+
**Stack:** OpenEnv · Unsloth · TRL (GRPO) · FastAPI · scikit-learn
|
| 306 |
+
**Repo:** [github.com/CelestialWorthyOfHeavenAndEarth/data-centric-env](https://github.com/CelestialWorthyOfHeavenAndEarth/data-centric-env)
|
| 307 |
+
**Space:** [huggingface.co/spaces/Aswini-Kumar/data-centric-env](https://huggingface.co/spaces/Aswini-Kumar/data-centric-env)
|
__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""Data Centric Env Environment."""
|
| 8 |
+
|
| 9 |
+
from .client import DataCentricEnv
|
| 10 |
+
from .models import DataCentricAction, DataCentricObservation
|
| 11 |
+
|
| 12 |
+
__all__ = [
|
| 13 |
+
"DataCentricAction",
|
| 14 |
+
"DataCentricObservation",
|
| 15 |
+
"DataCentricEnv",
|
| 16 |
+
]
|
client.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Data-Centric Environment Client."""
|
| 2 |
+
|
| 3 |
+
from typing import Dict
|
| 4 |
+
|
| 5 |
+
from openenv.core import EnvClient
|
| 6 |
+
from openenv.core.client_types import StepResult
|
| 7 |
+
from openenv.core.env_server.types import State
|
| 8 |
+
|
| 9 |
+
try:
|
| 10 |
+
from .models import DataCentricAction, DataCentricObservation
|
| 11 |
+
except ImportError:
|
| 12 |
+
from models import DataCentricAction, DataCentricObservation
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class DataCentricEnv(EnvClient[DataCentricAction, DataCentricObservation, State]):
|
| 16 |
+
"""
|
| 17 |
+
Client for the Data-Centric RL Environment.
|
| 18 |
+
|
| 19 |
+
Connects over WebSocket for efficient multi-step interactions.
|
| 20 |
+
|
| 21 |
+
Example:
|
| 22 |
+
>>> with DataCentricEnv(base_url="http://localhost:8000") as client:
|
| 23 |
+
... result = client.reset(task="task_0_tutorial")
|
| 24 |
+
... result = client.step(DataCentricAction(message="inspect_dataset"))
|
| 25 |
+
... print(result.observation.response)
|
| 26 |
+
|
| 27 |
+
Docker example:
|
| 28 |
+
>>> client = DataCentricEnv.from_docker_image("data_centric_env:latest")
|
| 29 |
+
>>> result = client.reset(task="task_1_easy")
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
def _step_payload(self, action: DataCentricAction) -> Dict:
|
| 33 |
+
return {"message": action.message}
|
| 34 |
+
|
| 35 |
+
def _parse_result(self, payload: Dict) -> StepResult[DataCentricObservation]:
|
| 36 |
+
obs_data = payload.get("observation", {})
|
| 37 |
+
observation = DataCentricObservation(
|
| 38 |
+
response=obs_data.get("response", ""),
|
| 39 |
+
current_accuracy=obs_data.get("current_accuracy", 0.0),
|
| 40 |
+
baseline_accuracy=obs_data.get("baseline_accuracy", 0.0),
|
| 41 |
+
target_accuracy=obs_data.get("target_accuracy", 0.0),
|
| 42 |
+
estimated_quality=obs_data.get("estimated_quality", 0.0),
|
| 43 |
+
dataset_shape=obs_data.get("dataset_shape", ""),
|
| 44 |
+
rows_preserved_pct=obs_data.get("rows_preserved_pct", 1.0),
|
| 45 |
+
budget_remaining=obs_data.get("budget_remaining", 0),
|
| 46 |
+
step_number=obs_data.get("step_number", 0),
|
| 47 |
+
max_steps=obs_data.get("max_steps", 30),
|
| 48 |
+
active_session=obs_data.get("active_session", "none"),
|
| 49 |
+
validate_calls_remaining=obs_data.get("validate_calls_remaining", 3),
|
| 50 |
+
done=payload.get("done", False),
|
| 51 |
+
reward=payload.get("reward", 0.0),
|
| 52 |
+
metadata=obs_data.get("metadata", {}),
|
| 53 |
+
)
|
| 54 |
+
return StepResult(
|
| 55 |
+
observation=observation,
|
| 56 |
+
reward=payload.get("reward", 0.0),
|
| 57 |
+
done=payload.get("done", False),
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
def _parse_state(self, payload: Dict) -> State:
|
| 61 |
+
return State(
|
| 62 |
+
episode_id=payload.get("episode_id"),
|
| 63 |
+
step_count=payload.get("step_count", 0),
|
| 64 |
+
)
|
eval_data_centric.py
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
eval_data_centric.py — Evaluation script for DataCentricEnv.
|
| 3 |
+
|
| 4 |
+
Runs two agents on identical seeds for fair comparison:
|
| 5 |
+
- Random Agent: picks valid commands at random (baseline)
|
| 6 |
+
- Trained Agent: uses the fine-tuned model from ./data-centric-adapter
|
| 7 |
+
|
| 8 |
+
Produces eval_results.json for use by plot_rewards.py.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
import random
|
| 14 |
+
import signal
|
| 15 |
+
import subprocess
|
| 16 |
+
import sys
|
| 17 |
+
import time
|
| 18 |
+
from typing import Optional
|
| 19 |
+
|
| 20 |
+
import requests
|
| 21 |
+
import torch
|
| 22 |
+
from unsloth import FastLanguageModel
|
| 23 |
+
|
| 24 |
+
# WebSocket client for stateful episode rollouts
|
| 25 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 26 |
+
from client import DataCentricEnv
|
| 27 |
+
from models import DataCentricAction
|
| 28 |
+
|
| 29 |
+
# ════════════════════════════════════════════════════════
|
| 30 |
+
# CONSTANTS
|
| 31 |
+
# ════════════════════════════════════════════════════════
|
| 32 |
+
|
| 33 |
+
BASE_URL = os.environ.get("ENV_URL", "http://localhost:8000")
|
| 34 |
+
ADAPTER_PATH = "./data-centric-adapter"
|
| 35 |
+
MAX_SEQ_LENGTH = 1024
|
| 36 |
+
EPISODES_PER_TASK = 10
|
| 37 |
+
TASKS = ["task_0_tutorial", "task_1_easy", "task_2_medium", "task_3_hard"]
|
| 38 |
+
|
| 39 |
+
VALID_COMMANDS = [
|
| 40 |
+
"inspect_dataset", "inspect_model", "query_analyst",
|
| 41 |
+
"query_cleaner", "query_augmenter 0", "query_balancer", "query_validator",
|
| 42 |
+
"apply 1", "apply 2", "reject 1", "undo", "validate", "submit",
|
| 43 |
+
]
|
| 44 |
+
|
| 45 |
+
SYSTEM_PROMPT = """You are a Data-Centric AI agent. Your job is to improve a \
|
| 46 |
+
Machine learning dataset so a fixed classifier achieves higher accuracy.
|
| 47 |
+
|
| 48 |
+
STRATEGY — use this order:
|
| 49 |
+
1. query_analyst to get a prioritised action plan (costs 1 budget, worth it)
|
| 50 |
+
2. inspect_dataset to understand the data
|
| 51 |
+
3. query the recommended specialist (query_cleaner, query_augmenter, query_balancer)
|
| 52 |
+
4. apply the best recommendation by number (e.g. apply 1)
|
| 53 |
+
5. validate to check if accuracy improved
|
| 54 |
+
6. repeat until you hit the target or run low on budget
|
| 55 |
+
7. submit to finalize
|
| 56 |
+
|
| 57 |
+
IMPORTANT RULES:
|
| 58 |
+
- Start with query_analyst — it tells you the biggest problem to fix first.
|
| 59 |
+
- Always query a specialist before applying. Never apply without querying first.
|
| 60 |
+
- Check the Agreement signal after validate: DISAGREE means possible overfitting.
|
| 61 |
+
- Validate after every 2-3 applies to track progress.
|
| 62 |
+
- Do not spam validate — it costs budget after 3 uses.
|
| 63 |
+
- query_validator costs 2 budget — use only when suspicious of data quality.
|
| 64 |
+
- submit when accuracy >= target or budget < 5.
|
| 65 |
+
|
| 66 |
+
Reply with exactly ONE command per message. No explanation. Just the command."""
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def build_user_prompt(obs: dict) -> str:
|
| 70 |
+
improvement_needed = obs.get("target_accuracy", 0) - obs.get("current_accuracy", 0)
|
| 71 |
+
return (
|
| 72 |
+
f"Current situation:\n"
|
| 73 |
+
f"Accuracy: {obs.get('current_accuracy', 0):.1%} → "
|
| 74 |
+
f"Target: {obs.get('target_accuracy', 0):.1%}\n"
|
| 75 |
+
f"Still need: {max(0, improvement_needed):.1%} improvement\n"
|
| 76 |
+
f"Quality score: {obs.get('estimated_quality', 0):.2f}/1.00\n"
|
| 77 |
+
f"Rows preserved: {obs.get('rows_preserved_pct', 1.0):.1%}\n"
|
| 78 |
+
f"Budget remaining: {obs.get('budget_remaining', 0)} steps\n"
|
| 79 |
+
f"Free validates left: {obs.get('validate_calls_remaining', 0)}\n"
|
| 80 |
+
f"Active query session: {obs.get('active_session', 'none')}\n\n"
|
| 81 |
+
f"Last result:\n{str(obs.get('response', ''))[:400]}\n\n"
|
| 82 |
+
f"What is your next action? (one command only)"
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# ════════════════════════════════════════════════════════
|
| 87 |
+
# SERVER MANAGEMENT
|
| 88 |
+
# ════════════════════════════════════════════════════════
|
| 89 |
+
|
| 90 |
+
def start_server() -> subprocess.Popen:
|
| 91 |
+
proc = subprocess.Popen(
|
| 92 |
+
["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000"],
|
| 93 |
+
stdout=subprocess.DEVNULL,
|
| 94 |
+
stderr=subprocess.DEVNULL,
|
| 95 |
+
)
|
| 96 |
+
for i in range(30):
|
| 97 |
+
try:
|
| 98 |
+
r = requests.get(f"{BASE_URL}/health", timeout=1)
|
| 99 |
+
if r.status_code == 200:
|
| 100 |
+
print(f"Server ready after {i + 1}s")
|
| 101 |
+
return proc
|
| 102 |
+
except Exception:
|
| 103 |
+
pass
|
| 104 |
+
time.sleep(1)
|
| 105 |
+
proc.terminate()
|
| 106 |
+
raise RuntimeError("Server failed to start in 30 seconds")
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def stop_server(proc: subprocess.Popen):
|
| 110 |
+
proc.terminate() # cross-platform (SIGTERM on Linux, TerminateProcess on Windows)
|
| 111 |
+
proc.wait()
|
| 112 |
+
print("Server stopped.")
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# ════════════════════════════════════════════════════════
|
| 116 |
+
# MODEL LOADING
|
| 117 |
+
# ════════════════════════════════════════════════════════
|
| 118 |
+
|
| 119 |
+
def load_trained_model():
|
| 120 |
+
if not os.path.exists(ADAPTER_PATH):
|
| 121 |
+
raise FileNotFoundError(
|
| 122 |
+
f"Adapter not found at {ADAPTER_PATH}. "
|
| 123 |
+
"Run train_data_centric.py first."
|
| 124 |
+
)
|
| 125 |
+
model, tokenizer = FastLanguageModel.from_pretrained(
|
| 126 |
+
model_name=ADAPTER_PATH,
|
| 127 |
+
max_seq_length=MAX_SEQ_LENGTH,
|
| 128 |
+
load_in_4bit=True,
|
| 129 |
+
dtype=None,
|
| 130 |
+
)
|
| 131 |
+
FastLanguageModel.for_inference(model)
|
| 132 |
+
return model, tokenizer
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# ════════════════════════════════════════════════════════
|
| 136 |
+
# EPISODE METRICS
|
| 137 |
+
# ════════════════════════════════════════════════════════
|
| 138 |
+
|
| 139 |
+
def episode_metrics(
|
| 140 |
+
task: str,
|
| 141 |
+
seed: int,
|
| 142 |
+
final_obs: dict,
|
| 143 |
+
actions: list,
|
| 144 |
+
baseline_accuracy: float,
|
| 145 |
+
max_steps: int,
|
| 146 |
+
) -> dict:
|
| 147 |
+
"""Compute per-episode metrics for a single completed episode."""
|
| 148 |
+
final_accuracy = final_obs.get("current_accuracy", baseline_accuracy)
|
| 149 |
+
budget_remaining = final_obs.get("budget_remaining", 0)
|
| 150 |
+
target_accuracy = final_obs.get("target_accuracy", 1.0)
|
| 151 |
+
budget_used = max_steps - budget_remaining
|
| 152 |
+
|
| 153 |
+
accuracy_improvement = final_accuracy - baseline_accuracy
|
| 154 |
+
target_hit = final_accuracy >= target_accuracy
|
| 155 |
+
budget_efficiency = (
|
| 156 |
+
accuracy_improvement / max(budget_used, 1)
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
# Format rate: % actions that are valid commands
|
| 160 |
+
valid_count = sum(
|
| 161 |
+
1 for a in actions
|
| 162 |
+
if any(a.strip().startswith(cmd.split()[0]) for cmd in VALID_COMMANDS)
|
| 163 |
+
)
|
| 164 |
+
format_rate = valid_count / max(len(actions), 1)
|
| 165 |
+
|
| 166 |
+
# Strategy rate: % query→apply consecutive pairs
|
| 167 |
+
strategy_hits = 0
|
| 168 |
+
for i in range(1, len(actions)):
|
| 169 |
+
if (actions[i - 1].startswith("query_")
|
| 170 |
+
and actions[i].startswith("apply")):
|
| 171 |
+
strategy_hits += 1
|
| 172 |
+
strategy_rate = strategy_hits / max(len(actions) - 1, 1)
|
| 173 |
+
|
| 174 |
+
return {
|
| 175 |
+
"task": task,
|
| 176 |
+
"seed": seed,
|
| 177 |
+
"final_accuracy": round(final_accuracy, 4),
|
| 178 |
+
"baseline_accuracy": round(baseline_accuracy, 4),
|
| 179 |
+
"target_accuracy": round(target_accuracy, 4),
|
| 180 |
+
"accuracy_improvement": round(accuracy_improvement, 4),
|
| 181 |
+
"target_hit": target_hit,
|
| 182 |
+
"budget_used": budget_used,
|
| 183 |
+
"budget_efficiency": round(budget_efficiency, 6),
|
| 184 |
+
"format_rate": round(format_rate, 4),
|
| 185 |
+
"strategy_rate": round(strategy_rate, 4),
|
| 186 |
+
"n_actions": len(actions),
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
# ════════════════════════════════════════════════════════
|
| 191 |
+
# RANDOM AGENT
|
| 192 |
+
# ════════════════════════════════════════════════════════
|
| 193 |
+
|
| 194 |
+
def run_random_episode(task: str, seed: int) -> Optional[dict]:
|
| 195 |
+
"""Run one episode with a random agent using the WebSocket client."""
|
| 196 |
+
try:
|
| 197 |
+
with DataCentricEnv(base_url=BASE_URL).sync() as env:
|
| 198 |
+
r_reset = env.reset(task=task, seed=seed)
|
| 199 |
+
obs = r_reset.observation
|
| 200 |
+
baseline_accuracy = obs.baseline_accuracy
|
| 201 |
+
max_steps = obs.max_steps
|
| 202 |
+
actions = []
|
| 203 |
+
|
| 204 |
+
while not obs.done:
|
| 205 |
+
action = random.choice(VALID_COMMANDS)
|
| 206 |
+
actions.append(action)
|
| 207 |
+
try:
|
| 208 |
+
step_result = env.step(DataCentricAction(message=action))
|
| 209 |
+
obs = step_result.observation
|
| 210 |
+
except Exception:
|
| 211 |
+
break
|
| 212 |
+
|
| 213 |
+
return episode_metrics(
|
| 214 |
+
task, seed,
|
| 215 |
+
{"current_accuracy": obs.current_accuracy,
|
| 216 |
+
"budget_remaining": obs.budget_remaining,
|
| 217 |
+
"target_accuracy": obs.target_accuracy,
|
| 218 |
+
"done": obs.done},
|
| 219 |
+
actions, baseline_accuracy, max_steps
|
| 220 |
+
)
|
| 221 |
+
except Exception as e:
|
| 222 |
+
print(f" [random] Episode failed: {e}")
|
| 223 |
+
return None
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
# ════════════════════════════════════════════════════════
|
| 227 |
+
# TRAINED AGENT
|
| 228 |
+
# ════════════════════════════════════════════════════════
|
| 229 |
+
|
| 230 |
+
def run_trained_episode(
|
| 231 |
+
model, tokenizer, task: str, seed: int
|
| 232 |
+
) -> Optional[dict]:
|
| 233 |
+
"""Run one episode with the trained model using the WebSocket client."""
|
| 234 |
+
try:
|
| 235 |
+
with DataCentricEnv(base_url=BASE_URL).sync() as env:
|
| 236 |
+
r_reset = env.reset(task=task, seed=seed)
|
| 237 |
+
obs = r_reset.observation
|
| 238 |
+
baseline_accuracy = obs.baseline_accuracy
|
| 239 |
+
max_steps = obs.max_steps
|
| 240 |
+
actions = []
|
| 241 |
+
|
| 242 |
+
while not obs.done:
|
| 243 |
+
obs_dict = {
|
| 244 |
+
"current_accuracy": obs.current_accuracy,
|
| 245 |
+
"target_accuracy": obs.target_accuracy,
|
| 246 |
+
"estimated_quality": obs.estimated_quality,
|
| 247 |
+
"rows_preserved_pct": obs.rows_preserved_pct,
|
| 248 |
+
"budget_remaining": obs.budget_remaining,
|
| 249 |
+
"validate_calls_remaining":obs.validate_calls_remaining,
|
| 250 |
+
"active_session": obs.active_session,
|
| 251 |
+
"response": obs.response,
|
| 252 |
+
}
|
| 253 |
+
messages = [
|
| 254 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 255 |
+
{"role": "user", "content": build_user_prompt(obs_dict)},
|
| 256 |
+
]
|
| 257 |
+
input_ids = tokenizer.apply_chat_template(
|
| 258 |
+
messages,
|
| 259 |
+
return_tensors="pt",
|
| 260 |
+
max_length=MAX_SEQ_LENGTH - 60,
|
| 261 |
+
truncation=True,
|
| 262 |
+
add_generation_prompt=True,
|
| 263 |
+
).to(model.device)
|
| 264 |
+
|
| 265 |
+
with torch.no_grad():
|
| 266 |
+
output_ids = model.generate(
|
| 267 |
+
input_ids,
|
| 268 |
+
max_new_tokens=50,
|
| 269 |
+
temperature=0.1,
|
| 270 |
+
do_sample=True,
|
| 271 |
+
pad_token_id=tokenizer.eos_token_id,
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
action = tokenizer.decode(
|
| 275 |
+
output_ids[0][input_ids.shape[1]:],
|
| 276 |
+
skip_special_tokens=True,
|
| 277 |
+
).strip().split("\n")[0].strip()[:200]
|
| 278 |
+
|
| 279 |
+
actions.append(action)
|
| 280 |
+
try:
|
| 281 |
+
step_result = env.step(DataCentricAction(message=action))
|
| 282 |
+
obs = step_result.observation
|
| 283 |
+
except Exception as e:
|
| 284 |
+
break
|
| 285 |
+
|
| 286 |
+
return episode_metrics(
|
| 287 |
+
task, seed,
|
| 288 |
+
{"current_accuracy": obs.current_accuracy,
|
| 289 |
+
"budget_remaining": obs.budget_remaining,
|
| 290 |
+
"target_accuracy": obs.target_accuracy,
|
| 291 |
+
"done": obs.done},
|
| 292 |
+
actions, baseline_accuracy, max_steps
|
| 293 |
+
)
|
| 294 |
+
except Exception as e:
|
| 295 |
+
print(f" [trained] Episode failed: {e}")
|
| 296 |
+
return None
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
# ════════════════════════════════════════════════════════
|
| 300 |
+
# AGGREGATION
|
| 301 |
+
# ════════════════════════════════════════════════════════
|
| 302 |
+
|
| 303 |
+
def aggregate(episodes: list) -> dict:
|
| 304 |
+
"""Compute mean metrics across a list of episode result dicts."""
|
| 305 |
+
if not episodes:
|
| 306 |
+
return {}
|
| 307 |
+
keys = [
|
| 308 |
+
"accuracy_improvement", "target_hit", "budget_used",
|
| 309 |
+
"budget_efficiency", "format_rate", "strategy_rate",
|
| 310 |
+
]
|
| 311 |
+
return {
|
| 312 |
+
k: round(sum(ep[k] for ep in episodes) / len(episodes), 4)
|
| 313 |
+
for k in keys
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def print_comparison_table(random_agg: dict, trained_agg: dict):
|
| 318 |
+
"""Print a formatted comparison table to stdout."""
|
| 319 |
+
def pct_change(r, t):
|
| 320 |
+
if r == 0:
|
| 321 |
+
return "—"
|
| 322 |
+
return f"{(t - r) / abs(r) * 100:+.0f}%"
|
| 323 |
+
|
| 324 |
+
def pp_change(r, t):
|
| 325 |
+
return f"{(t - r) * 100:+.0f}pp"
|
| 326 |
+
|
| 327 |
+
rows = [
|
| 328 |
+
("Accuracy gain", f"{random_agg.get('accuracy_improvement',0):.3f}",
|
| 329 |
+
f"{trained_agg.get('accuracy_improvement',0):.3f}",
|
| 330 |
+
pct_change(random_agg.get('accuracy_improvement',0),
|
| 331 |
+
trained_agg.get('accuracy_improvement',0))),
|
| 332 |
+
("Target hit rate", f"{random_agg.get('target_hit',0):.0%}",
|
| 333 |
+
f"{trained_agg.get('target_hit',0):.0%}",
|
| 334 |
+
pp_change(random_agg.get('target_hit',0),
|
| 335 |
+
trained_agg.get('target_hit',0))),
|
| 336 |
+
("Budget efficiency", f"{random_agg.get('budget_efficiency',0):.4f}",
|
| 337 |
+
f"{trained_agg.get('budget_efficiency',0):.4f}",
|
| 338 |
+
pct_change(random_agg.get('budget_efficiency',0),
|
| 339 |
+
trained_agg.get('budget_efficiency',0))),
|
| 340 |
+
("Format rate", "random",
|
| 341 |
+
f"{trained_agg.get('format_rate',0):.0%}", "—"),
|
| 342 |
+
("Strategy rate", "0%",
|
| 343 |
+
f"{trained_agg.get('strategy_rate',0):.0%}", "—"),
|
| 344 |
+
]
|
| 345 |
+
|
| 346 |
+
header = f"{'Metric':<20} {'Random':>12} {'Trained':>13} {'Improvement':>14}"
|
| 347 |
+
sep = "─" * len(header)
|
| 348 |
+
print(f"\n{sep}")
|
| 349 |
+
print(header)
|
| 350 |
+
print(sep)
|
| 351 |
+
for metric, rand, trained, improvement in rows:
|
| 352 |
+
print(f" {metric:<18} {rand:>12} {trained:>13} {improvement:>14}")
|
| 353 |
+
print(sep + "\n")
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
# ════════════════════════════════════════════════════════
|
| 357 |
+
# MAIN
|
| 358 |
+
# ════════════════════════════════════════════════════════
|
| 359 |
+
|
| 360 |
+
if __name__ == "__main__":
|
| 361 |
+
server_proc = start_server()
|
| 362 |
+
|
| 363 |
+
try:
|
| 364 |
+
print(f"\nLoading trained model from {ADAPTER_PATH}...")
|
| 365 |
+
model, tokenizer = load_trained_model()
|
| 366 |
+
|
| 367 |
+
# Use fixed seeds so both agents see identical tasks
|
| 368 |
+
seeds = list(range(EPISODES_PER_TASK))
|
| 369 |
+
|
| 370 |
+
results = {
|
| 371 |
+
"random": {"all_episodes": [], "by_task": {}},
|
| 372 |
+
"trained": {"all_episodes": [], "by_task": {}},
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
for task in TASKS:
|
| 376 |
+
print(f"\n{'='*50}")
|
| 377 |
+
print(f"Task: {task}")
|
| 378 |
+
print("─" * 50)
|
| 379 |
+
|
| 380 |
+
random_eps, trained_eps = [], []
|
| 381 |
+
|
| 382 |
+
for seed in seeds:
|
| 383 |
+
print(f" Seed {seed:2d}:", end=" ")
|
| 384 |
+
|
| 385 |
+
# Random agent
|
| 386 |
+
sys.stdout.write("[random] ")
|
| 387 |
+
sys.stdout.flush()
|
| 388 |
+
r_ep = run_random_episode(task, seed)
|
| 389 |
+
if r_ep:
|
| 390 |
+
random_eps.append(r_ep)
|
| 391 |
+
sys.stdout.write(
|
| 392 |
+
f"acc={r_ep['final_accuracy']:.3f} "
|
| 393 |
+
f"hit={'✓' if r_ep['target_hit'] else '✗'} "
|
| 394 |
+
)
|
| 395 |
+
|
| 396 |
+
# Trained agent (same seed)
|
| 397 |
+
sys.stdout.write("[trained] ")
|
| 398 |
+
sys.stdout.flush()
|
| 399 |
+
t_ep = run_trained_episode(model, tokenizer, task, seed)
|
| 400 |
+
if t_ep:
|
| 401 |
+
trained_eps.append(t_ep)
|
| 402 |
+
sys.stdout.write(
|
| 403 |
+
f"acc={t_ep['final_accuracy']:.3f} "
|
| 404 |
+
f"hit={'✓' if t_ep['target_hit'] else '✗'}"
|
| 405 |
+
)
|
| 406 |
+
|
| 407 |
+
print()
|
| 408 |
+
|
| 409 |
+
results["random"]["by_task"][task] = aggregate(random_eps)
|
| 410 |
+
results["trained"]["by_task"][task] = aggregate(trained_eps)
|
| 411 |
+
results["random"]["all_episodes"].extend(random_eps)
|
| 412 |
+
results["trained"]["all_episodes"].extend(trained_eps)
|
| 413 |
+
|
| 414 |
+
# Overall aggregates
|
| 415 |
+
results["random"]["overall"] = aggregate(results["random"]["all_episodes"])
|
| 416 |
+
results["trained"]["overall"] = aggregate(results["trained"]["all_episodes"])
|
| 417 |
+
|
| 418 |
+
# Print comparison table
|
| 419 |
+
print_comparison_table(
|
| 420 |
+
results["random"]["overall"],
|
| 421 |
+
results["trained"]["overall"],
|
| 422 |
+
)
|
| 423 |
+
|
| 424 |
+
# Print per-task breakdown
|
| 425 |
+
print("Per-task summary:")
|
| 426 |
+
for task in TASKS:
|
| 427 |
+
r = results["random"]["by_task"].get(task, {})
|
| 428 |
+
t = results["trained"]["by_task"].get(task, {})
|
| 429 |
+
print(
|
| 430 |
+
f" {task:<22} "
|
| 431 |
+
f"random: acc+{r.get('accuracy_improvement',0):.3f} "
|
| 432 |
+
f"hit={r.get('target_hit',0):.0%} | "
|
| 433 |
+
f"trained: acc+{t.get('accuracy_improvement',0):.3f} "
|
| 434 |
+
f"hit={t.get('target_hit',0):.0%}"
|
| 435 |
+
)
|
| 436 |
+
|
| 437 |
+
# Save full results
|
| 438 |
+
json.dump(results, open("eval_results.json", "w"), indent=2)
|
| 439 |
+
print("\nResults saved to eval_results.json")
|
| 440 |
+
print("Run plot_rewards.py to visualise.")
|
| 441 |
+
|
| 442 |
+
finally:
|
| 443 |
+
stop_server(server_proc)
|
hf_job_train.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
HF Job training script for Data-Centric AI Agent.
|
| 4 |
+
|
| 5 |
+
Run this as an HF Job pointing to the deployed HF Space as the environment:
|
| 6 |
+
hf job run --gpu t4-small --env ENV_URL=https://aswini-kumar-data-centric-env.hf.space \
|
| 7 |
+
python hf_job_train.py
|
| 8 |
+
|
| 9 |
+
Or via Python API:
|
| 10 |
+
from huggingface_hub import HfApi
|
| 11 |
+
api = HfApi()
|
| 12 |
+
api.run_job(...)
|
| 13 |
+
|
| 14 |
+
The HF Space (server) must be running BEFORE submitting this job.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import os
|
| 18 |
+
import sys
|
| 19 |
+
import time
|
| 20 |
+
import requests
|
| 21 |
+
|
| 22 |
+
# ── Verify the HF Space is reachable before starting training ─────────────────
|
| 23 |
+
|
| 24 |
+
ENV_URL = os.environ.get("ENV_URL", "https://aswini-kumar-data-centric-env.hf.space")
|
| 25 |
+
print(f"[HF Job] Environment URL: {ENV_URL}")
|
| 26 |
+
|
| 27 |
+
print("[HF Job] Checking environment server health...")
|
| 28 |
+
for attempt in range(12):
|
| 29 |
+
try:
|
| 30 |
+
r = requests.get(f"{ENV_URL}/health", timeout=10)
|
| 31 |
+
if r.status_code == 200:
|
| 32 |
+
print(f"[HF Job] Server healthy: {r.json()}")
|
| 33 |
+
break
|
| 34 |
+
except Exception as e:
|
| 35 |
+
print(f"[HF Job] Attempt {attempt+1}/12: {e}")
|
| 36 |
+
time.sleep(10)
|
| 37 |
+
else:
|
| 38 |
+
raise RuntimeError(
|
| 39 |
+
f"HF Space at {ENV_URL} is not responding after 2 minutes.\n"
|
| 40 |
+
"Make sure the Space is Running before submitting this job."
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
# ── Install dependencies ───────────────────────────────────────────────────────
|
| 44 |
+
|
| 45 |
+
print("[HF Job] Installing dependencies...")
|
| 46 |
+
os.system(
|
| 47 |
+
'pip install "unsloth[colab-new] @ git+https://github.com/unslothai/unsloth.git" '
|
| 48 |
+
'trl datasets transformers accelerate scikit-learn pandas numpy matplotlib '
|
| 49 |
+
'"openenv-core[core]>=0.2.1" --quiet'
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
# ── Pull latest environment code ──────────────────────────────────────────────
|
| 53 |
+
|
| 54 |
+
REPO = "https://github.com/CelestialWorthyOfHeavenAndEarth/data-centric-env.git"
|
| 55 |
+
WORK_DIR = "/tmp/data-centric-env"
|
| 56 |
+
|
| 57 |
+
if not os.path.exists(f"{WORK_DIR}/pyproject.toml"):
|
| 58 |
+
os.system(f"git clone {REPO} {WORK_DIR}")
|
| 59 |
+
else:
|
| 60 |
+
os.system(f"git -C {WORK_DIR} pull origin main")
|
| 61 |
+
|
| 62 |
+
os.chdir(WORK_DIR)
|
| 63 |
+
sys.path.insert(0, WORK_DIR)
|
| 64 |
+
os.system("pip install -e . --quiet")
|
| 65 |
+
print(f"[HF Job] Working dir: {os.getcwd()}")
|
| 66 |
+
os.system("git log --oneline -3")
|
| 67 |
+
|
| 68 |
+
# ── Set ENV_URL so train_data_centric.py uses the HF Space ───────────────────
|
| 69 |
+
|
| 70 |
+
os.environ["ENV_URL"] = ENV_URL
|
| 71 |
+
print(f"[HF Job] ENV_URL = {ENV_URL}")
|
| 72 |
+
|
| 73 |
+
# ── Generate SFT data ─────────────────────────────────────────────────────────
|
| 74 |
+
|
| 75 |
+
if not os.path.exists("sft_data.jsonl"):
|
| 76 |
+
print("[HF Job] Generating SFT data...")
|
| 77 |
+
os.system("python sft_generator.py")
|
| 78 |
+
else:
|
| 79 |
+
count = sum(1 for _ in open("sft_data.jsonl"))
|
| 80 |
+
print(f"[HF Job] SFT data exists: {count} examples")
|
| 81 |
+
|
| 82 |
+
# ── Run full training pipeline ────────────────────────────────────────────────
|
| 83 |
+
|
| 84 |
+
from train_data_centric import load_model, run_sft_warmup, run_grpo_training, save_model
|
| 85 |
+
import glob
|
| 86 |
+
|
| 87 |
+
print("[HF Job] Loading model...")
|
| 88 |
+
model, tokenizer = load_model()
|
| 89 |
+
|
| 90 |
+
print("[HF Job] Phase 1: SFT warmup...")
|
| 91 |
+
model = run_sft_warmup(model, tokenizer)
|
| 92 |
+
print("[HF Job] SFT complete")
|
| 93 |
+
|
| 94 |
+
print("[HF Job] Phase 2: GRPO training (connecting to HF Space)...")
|
| 95 |
+
resume_from = None
|
| 96 |
+
ckpt_dir = "./data-centric-checkpoints"
|
| 97 |
+
if os.path.exists(ckpt_dir):
|
| 98 |
+
checkpoints = sorted(glob.glob(f"{ckpt_dir}/checkpoint-*"))
|
| 99 |
+
if checkpoints:
|
| 100 |
+
resume_from = checkpoints[-1]
|
| 101 |
+
print(f"[HF Job] Resuming from: {resume_from}")
|
| 102 |
+
|
| 103 |
+
model = run_grpo_training(model, tokenizer, resume_from_checkpoint=resume_from)
|
| 104 |
+
print("[HF Job] GRPO complete")
|
| 105 |
+
|
| 106 |
+
print("[HF Job] Saving model...")
|
| 107 |
+
save_model(model, tokenizer)
|
| 108 |
+
|
| 109 |
+
print("[HF Job] Generating reward plots...")
|
| 110 |
+
os.system("python plot_rewards.py --log logs/training.jsonl --out plots/")
|
| 111 |
+
|
| 112 |
+
print("[HF Job] Done! Results in ./logs/ and ./plots/")
|
inference.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Heuristic baseline agent for the Data-Centric RL Environment.
|
| 3 |
+
|
| 4 |
+
Verifies the environment works correctly before any LLM training.
|
| 5 |
+
Run on all 4 tasks, 5 seeds each. Prints a results table.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import sys
|
| 9 |
+
import os
|
| 10 |
+
|
| 11 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 12 |
+
|
| 13 |
+
from models import DataCentricAction, DataCentricObservation
|
| 14 |
+
from server.data_centric_environment import DataCentricEnvironment
|
| 15 |
+
from server.dataset_generator import TASK_CONFIGS
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def heuristic_agent(obs: DataCentricObservation, step: int, state: dict) -> str:
|
| 19 |
+
"""
|
| 20 |
+
Simple heuristic agent that follows:
|
| 21 |
+
inspect → query_cleaner → apply 1 → apply 2 → validate → query_balancer
|
| 22 |
+
→ apply 1 → validate → submit
|
| 23 |
+
"""
|
| 24 |
+
if step == 0:
|
| 25 |
+
return "inspect_dataset"
|
| 26 |
+
if not state.get("queried_cleaner"):
|
| 27 |
+
state["queried_cleaner"] = True
|
| 28 |
+
return "query_cleaner"
|
| 29 |
+
if state.get("cleaner_applies", 0) < 2:
|
| 30 |
+
n = state.get("cleaner_applies", 0) + 1
|
| 31 |
+
state["cleaner_applies"] = n
|
| 32 |
+
return f"apply {n}"
|
| 33 |
+
if not state.get("validated"):
|
| 34 |
+
state["validated"] = True
|
| 35 |
+
return "validate"
|
| 36 |
+
if obs.current_accuracy < obs.target_accuracy and not state.get("queried_balancer"):
|
| 37 |
+
state["queried_balancer"] = True
|
| 38 |
+
return "query_balancer"
|
| 39 |
+
if state.get("queried_balancer") and not state.get("balancer_applied"):
|
| 40 |
+
state["balancer_applied"] = True
|
| 41 |
+
return "apply 1"
|
| 42 |
+
if state.get("queried_balancer") and state.get("balancer_applied") and not state.get("validated2"):
|
| 43 |
+
state["validated2"] = True
|
| 44 |
+
return "validate"
|
| 45 |
+
return "submit"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def run_heuristic(task: str, seed: int) -> dict:
|
| 49 |
+
env = DataCentricEnvironment()
|
| 50 |
+
obs = env.reset(task=task, seed=seed)
|
| 51 |
+
state = {}
|
| 52 |
+
total_reward = 0.0
|
| 53 |
+
|
| 54 |
+
for step in range(TASK_CONFIGS[task]["budget"]):
|
| 55 |
+
action_msg = heuristic_agent(obs, step, state)
|
| 56 |
+
result_obs = env.step(DataCentricAction(message=action_msg))
|
| 57 |
+
total_reward += result_obs.reward
|
| 58 |
+
obs = result_obs
|
| 59 |
+
if obs.done:
|
| 60 |
+
break
|
| 61 |
+
|
| 62 |
+
return {
|
| 63 |
+
"task": task,
|
| 64 |
+
"seed": seed,
|
| 65 |
+
"final_accuracy": obs.current_accuracy,
|
| 66 |
+
"target": obs.target_accuracy,
|
| 67 |
+
"hit": obs.current_accuracy >= obs.target_accuracy,
|
| 68 |
+
"budget_used": obs.step_number,
|
| 69 |
+
"total_reward": round(total_reward, 4),
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def main():
|
| 74 |
+
tasks = list(TASK_CONFIGS.keys())
|
| 75 |
+
seeds = [0, 1, 2, 3, 4]
|
| 76 |
+
|
| 77 |
+
print(f"\n{'Task':<20} {'Seed':<6} {'Accuracy':<12} {'Target':<10} {'Hit?':<6} {'Budget':<10} {'Reward'}")
|
| 78 |
+
print("-" * 80)
|
| 79 |
+
|
| 80 |
+
hits = 0
|
| 81 |
+
total = 0
|
| 82 |
+
for task in tasks:
|
| 83 |
+
for seed in seeds:
|
| 84 |
+
r = run_heuristic(task, seed)
|
| 85 |
+
hit_str = "Y" if r["hit"] else "N"
|
| 86 |
+
if r["hit"]:
|
| 87 |
+
hits += 1
|
| 88 |
+
total += 1
|
| 89 |
+
print(
|
| 90 |
+
f"{r['task']:<20} {r['seed']:<6} {r['final_accuracy']:<12.4f} "
|
| 91 |
+
f"{r['target']:<10.4f} {hit_str:<6} {r['budget_used']:<10} {r['total_reward']}"
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
print("-" * 80)
|
| 95 |
+
print(f"Hit rate: {hits}/{total} ({100*hits/total:.0f}%)")
|
| 96 |
+
print()
|
| 97 |
+
if hits / total >= 0.6:
|
| 98 |
+
print(" PASS: Heuristic agent validation passed.")
|
| 99 |
+
else:
|
| 100 |
+
print(" WARN: Hit rate below 60%. Check environment tuning.")
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
if __name__ == "__main__":
|
| 104 |
+
main()
|
logs/.gitkeep
ADDED
|
File without changes
|
models.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
Data models for the Data-Centric RL Environment.
|
| 9 |
+
|
| 10 |
+
Action → plain text command string (like DataWranglerEnv)
|
| 11 |
+
Observation → rich structured observation with accuracy, quality, budget info
|
| 12 |
+
State → episode metadata
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from openenv.core.env_server.types import Action, Observation
|
| 16 |
+
from pydantic import Field
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class DataCentricAction(Action):
|
| 20 |
+
"""Action for the Data-Centric environment — a text command string.
|
| 21 |
+
|
| 22 |
+
The agent sends natural-language-style commands to inspect the dataset,
|
| 23 |
+
query specialist sub-agents, apply their recommendations, and ultimately
|
| 24 |
+
submit the cleaned dataset for scoring.
|
| 25 |
+
|
| 26 |
+
Examples:
|
| 27 |
+
- "inspect_dataset"
|
| 28 |
+
- "inspect_model"
|
| 29 |
+
- "query_cleaner"
|
| 30 |
+
- "query_augmenter class_1"
|
| 31 |
+
- "query_balancer"
|
| 32 |
+
- "query_validator"
|
| 33 |
+
- "apply 1"
|
| 34 |
+
- "reject 2"
|
| 35 |
+
- "validate"
|
| 36 |
+
- "submit"
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
message: str = Field(..., description="Text command to execute in the environment")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class DataCentricObservation(Observation):
|
| 43 |
+
"""Observation returned after each action in the Data-Centric environment.
|
| 44 |
+
|
| 45 |
+
Provides the agent with rich feedback about the current episode state,
|
| 46 |
+
including dataset health, model accuracy, budget, and specialist session info.
|
| 47 |
+
"""
|
| 48 |
+
|
| 49 |
+
response: str = Field(
|
| 50 |
+
default="",
|
| 51 |
+
description="Text result of the executed command",
|
| 52 |
+
)
|
| 53 |
+
current_accuracy: float = Field(
|
| 54 |
+
default=0.0,
|
| 55 |
+
description="Last validated model accuracy (or baseline if not yet validated)",
|
| 56 |
+
)
|
| 57 |
+
baseline_accuracy: float = Field(
|
| 58 |
+
default=0.0,
|
| 59 |
+
description="Accuracy at episode start — never changes",
|
| 60 |
+
)
|
| 61 |
+
target_accuracy: float = Field(
|
| 62 |
+
default=0.0,
|
| 63 |
+
description="Accuracy threshold the agent must exceed to hit target",
|
| 64 |
+
)
|
| 65 |
+
estimated_quality: float = Field(
|
| 66 |
+
default=0.0,
|
| 67 |
+
description="Lightweight quality score without sklearn retraining (0.0-1.0)",
|
| 68 |
+
)
|
| 69 |
+
dataset_shape: str = Field(
|
| 70 |
+
default="",
|
| 71 |
+
description="Current dataset dimensions, e.g. '200 rows × 5 columns'",
|
| 72 |
+
)
|
| 73 |
+
rows_preserved_pct: float = Field(
|
| 74 |
+
default=1.0,
|
| 75 |
+
description="Fraction of original rows still present (1.0 = no data loss)",
|
| 76 |
+
)
|
| 77 |
+
budget_remaining: int = Field(
|
| 78 |
+
default=0,
|
| 79 |
+
description="Steps remaining before forced submit",
|
| 80 |
+
)
|
| 81 |
+
step_number: int = Field(
|
| 82 |
+
default=0,
|
| 83 |
+
description="Current step number in the episode",
|
| 84 |
+
)
|
| 85 |
+
max_steps: int = Field(
|
| 86 |
+
default=30,
|
| 87 |
+
description="Maximum steps allowed for this task",
|
| 88 |
+
)
|
| 89 |
+
active_session: str = Field(
|
| 90 |
+
default="none",
|
| 91 |
+
description="Which specialist agent was queried last (cleaner/augmenter/balancer/none)",
|
| 92 |
+
)
|
| 93 |
+
validate_calls_remaining: int = Field(
|
| 94 |
+
default=3,
|
| 95 |
+
description="How many more free validates remain before reward turns negative",
|
| 96 |
+
)
|
| 97 |
+
done: bool = Field(
|
| 98 |
+
default=False,
|
| 99 |
+
description="Whether the episode has ended",
|
| 100 |
+
)
|
| 101 |
+
reward: float = Field(
|
| 102 |
+
default=0.0,
|
| 103 |
+
description="Reward for this step",
|
| 104 |
+
)
|
openenv.yaml
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: data-centric-env
|
| 2 |
+
description: >
|
| 3 |
+
Train an LLM agent to improve ML datasets using the Data-Centric AI paradigm.
|
| 4 |
+
The agent coordinates specialist sub-agents (CleanerAgent, AugmenterAgent,
|
| 5 |
+
BalancerAgent, ValidatorAgent) to fix data quality issues and improve a fixed
|
| 6 |
+
classifier's accuracy — without changing the model architecture or hyperparameters.
|
| 7 |
+
Based on Andrew Ng's Data-Centric AI framework.
|
| 8 |
+
version: "1.1.0"
|
| 9 |
+
tags:
|
| 10 |
+
- data-centric-ai
|
| 11 |
+
- world-modeling
|
| 12 |
+
- professional-tasks
|
| 13 |
+
- reinforcement-learning
|
| 14 |
+
- grpo
|
| 15 |
+
- unsloth
|
| 16 |
+
- curriculum-learning
|
| 17 |
+
|
| 18 |
+
tasks:
|
| 19 |
+
- name: task_0_tutorial
|
| 20 |
+
description: >
|
| 21 |
+
Single-issue tutorial. 100 rows, binary classification, 4 features.
|
| 22 |
+
Only issue: 15-30% missing values. Baseline ~0.62, target 0.73. Budget: 30 steps.
|
| 23 |
+
- name: task_1_easy
|
| 24 |
+
description: >
|
| 25 |
+
Two issues: missing values (8-25%) + mild class imbalance (5-20%).
|
| 26 |
+
200 rows, binary, 5 features. Baseline ~0.63, target 0.79. Budget: 25 steps.
|
| 27 |
+
- name: task_2_medium
|
| 28 |
+
description: >
|
| 29 |
+
Four issues: missing values, duplicates, class imbalance, type errors.
|
| 30 |
+
500 rows, 3-class, 7 features. Baseline ~0.58, target 0.74. Budget: 40 steps.
|
| 31 |
+
- name: task_3_hard
|
| 32 |
+
description: >
|
| 33 |
+
Six issues: missing values, duplicates, imbalance, type errors, outliers,
|
| 34 |
+
cross-column logic errors. 900 rows, 4-class, 10 features.
|
| 35 |
+
Baseline ~0.54, target 0.71. Budget: 60 steps.
|
| 36 |
+
|
| 37 |
+
action_type: text
|
| 38 |
+
observation_type: structured
|
| 39 |
+
|
| 40 |
+
# Reward components:
|
| 41 |
+
# accuracy_reward: [-1.0, +1.0] — accuracy delta × 2.5, submit bonus +0.50
|
| 42 |
+
# process_reward: [-0.10, +0.05] — workflow pattern scoring
|
| 43 |
+
# preservation_reward:[-0.40, +0.05] — row preservation incentive
|
| 44 |
+
# efficiency_reward: [-0.05, +0.20] — at submit only
|
| 45 |
+
# step_reward: [-0.30, +0.15] — proxy quality delta per apply
|
| 46 |
+
# Total clamped to [-1.0, 1.0] by compute_total_reward()
|
| 47 |
+
reward_range: [-1.0, 1.0]
|
plot_rewards.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Plot Rewards — Data-Centric AI RL Environment
|
| 3 |
+
=============================================
|
| 4 |
+
Reads JSONL training logs and produces judge-ready plots with labeled axes.
|
| 5 |
+
|
| 6 |
+
Log format (one JSON object per line in logs/training.jsonl):
|
| 7 |
+
{
|
| 8 |
+
"ts": 1714000000.0, # Unix timestamp
|
| 9 |
+
"episode": 42, # Episode number
|
| 10 |
+
"task": "task_1_easy", # Task name
|
| 11 |
+
"level": 1, # Curriculum level (0=tutorial ... 3=hard)
|
| 12 |
+
"reward": 0.34, # Episode reward
|
| 13 |
+
"accuracy_gain": 0.08, # Accuracy delta vs baseline
|
| 14 |
+
"steps_used": 18, # Steps consumed
|
| 15 |
+
"success": true # Reached target accuracy?
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
Output (saved to plots/):
|
| 19 |
+
reward_curve.png — Episode reward with rolling mean
|
| 20 |
+
success_rate.png — Success rate per curriculum level
|
| 21 |
+
accuracy_gain.png — Accuracy gain distribution
|
| 22 |
+
curriculum.png — Curriculum level over episodes
|
| 23 |
+
|
| 24 |
+
Usage:
|
| 25 |
+
python plot_rewards.py # default log path
|
| 26 |
+
python plot_rewards.py --log logs/training.jsonl --out plots/
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
import argparse
|
| 30 |
+
import json
|
| 31 |
+
import sys
|
| 32 |
+
from pathlib import Path
|
| 33 |
+
|
| 34 |
+
import matplotlib
|
| 35 |
+
matplotlib.use("Agg") # non-interactive backend — safe for headless/Colab
|
| 36 |
+
import matplotlib.pyplot as plt
|
| 37 |
+
import matplotlib.patches as mpatches
|
| 38 |
+
import numpy as np
|
| 39 |
+
import pandas as pd
|
| 40 |
+
|
| 41 |
+
# ── Style ────────────────────────────────────────────────────────────────────
|
| 42 |
+
|
| 43 |
+
LEVEL_COLORS = {0: "#4C72B0", 1: "#DD8452", 2: "#55A868", 3: "#C44E52"}
|
| 44 |
+
LEVEL_NAMES = {0: "tutorial", 1: "easy", 2: "medium", 3: "hard"}
|
| 45 |
+
FIGSIZE = (10, 4)
|
| 46 |
+
DPI = 150
|
| 47 |
+
|
| 48 |
+
plt.rcParams.update({
|
| 49 |
+
"font.size": 11,
|
| 50 |
+
"axes.titlesize": 13,
|
| 51 |
+
"axes.titleweight": "bold",
|
| 52 |
+
"axes.labelsize": 11,
|
| 53 |
+
"grid.alpha": 0.3,
|
| 54 |
+
})
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ── Load log ─────────────────────────────────────────────────────────────────
|
| 58 |
+
|
| 59 |
+
def load_log(log_path: str) -> pd.DataFrame:
|
| 60 |
+
"""Load JSONL training log. Returns empty DataFrame if file not found."""
|
| 61 |
+
path = Path(log_path)
|
| 62 |
+
if not path.exists():
|
| 63 |
+
print(f"[plot_rewards] Log not found: {log_path}")
|
| 64 |
+
return pd.DataFrame()
|
| 65 |
+
|
| 66 |
+
records = []
|
| 67 |
+
with open(path, encoding="utf-8") as f:
|
| 68 |
+
for line in f:
|
| 69 |
+
line = line.strip()
|
| 70 |
+
if line:
|
| 71 |
+
try:
|
| 72 |
+
records.append(json.loads(line))
|
| 73 |
+
except json.JSONDecodeError:
|
| 74 |
+
pass
|
| 75 |
+
|
| 76 |
+
if not records:
|
| 77 |
+
print(f"[plot_rewards] Log is empty: {log_path}")
|
| 78 |
+
return pd.DataFrame()
|
| 79 |
+
|
| 80 |
+
df = pd.DataFrame(records)
|
| 81 |
+
# Normalise column names — handle both old and new log formats
|
| 82 |
+
col_map = {
|
| 83 |
+
"mean_total_reward": "reward",
|
| 84 |
+
"mean_env_reward": "accuracy_gain",
|
| 85 |
+
"stage": "task",
|
| 86 |
+
}
|
| 87 |
+
df.rename(columns=col_map, inplace=True)
|
| 88 |
+
if "episode" not in df.columns:
|
| 89 |
+
df["episode"] = range(len(df))
|
| 90 |
+
if "level" not in df.columns:
|
| 91 |
+
df["level"] = 0
|
| 92 |
+
if "success" not in df.columns:
|
| 93 |
+
df["success"] = df.get("accuracy_gain", 0) > 0.05
|
| 94 |
+
if "accuracy_gain" not in df.columns:
|
| 95 |
+
df["accuracy_gain"] = 0.0
|
| 96 |
+
if "reward" not in df.columns:
|
| 97 |
+
df["reward"] = 0.0
|
| 98 |
+
|
| 99 |
+
df.sort_values("episode", inplace=True)
|
| 100 |
+
df.reset_index(drop=True, inplace=True)
|
| 101 |
+
print(f"[plot_rewards] Loaded {len(df)} episodes from {log_path}")
|
| 102 |
+
return df
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# ── Plots ─────────────────────────────────────────────────────────────────────
|
| 106 |
+
|
| 107 |
+
def plot_reward_curve(df: pd.DataFrame, out_dir: Path, window: int = 20):
|
| 108 |
+
"""Plot 1: Episode reward over training with rolling mean."""
|
| 109 |
+
fig, ax = plt.subplots(figsize=FIGSIZE)
|
| 110 |
+
|
| 111 |
+
ax.plot(df["episode"], df["reward"], alpha=0.25, color="steelblue",
|
| 112 |
+
linewidth=0.8, label="Raw reward")
|
| 113 |
+
|
| 114 |
+
if len(df) >= window:
|
| 115 |
+
smooth = df["reward"].rolling(window, min_periods=1).mean()
|
| 116 |
+
ax.plot(df["episode"], smooth, color="steelblue", linewidth=2.2,
|
| 117 |
+
label=f"Rolling mean (window={window})")
|
| 118 |
+
|
| 119 |
+
# Mark curriculum level transitions
|
| 120 |
+
level_changes = df[df["level"].diff() != 0]
|
| 121 |
+
for _, row in level_changes.iterrows():
|
| 122 |
+
if row["level"] > 0:
|
| 123 |
+
ax.axvline(row["episode"], color=LEVEL_COLORS.get(int(row["level"]), "gray"),
|
| 124 |
+
linewidth=1.0, linestyle="--", alpha=0.7)
|
| 125 |
+
ax.text(row["episode"] + 0.5, ax.get_ylim()[1] * 0.95,
|
| 126 |
+
LEVEL_NAMES.get(int(row["level"]), ""), fontsize=8,
|
| 127 |
+
color=LEVEL_COLORS.get(int(row["level"]), "gray"), rotation=90, va="top")
|
| 128 |
+
|
| 129 |
+
ax.set_xlabel("Episode")
|
| 130 |
+
ax.set_ylabel("Episode reward")
|
| 131 |
+
ax.set_title("Training Reward over Episodes")
|
| 132 |
+
ax.legend(loc="lower right")
|
| 133 |
+
ax.grid(True)
|
| 134 |
+
fig.tight_layout()
|
| 135 |
+
|
| 136 |
+
out_path = out_dir / "reward_curve.png"
|
| 137 |
+
fig.savefig(out_path, dpi=DPI)
|
| 138 |
+
plt.close(fig)
|
| 139 |
+
print(f"[plot_rewards] Saved: {out_path}")
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def plot_success_rate(df: pd.DataFrame, out_dir: Path, window: int = 20):
|
| 143 |
+
"""Plot 2: Success rate per curriculum level."""
|
| 144 |
+
fig, ax = plt.subplots(figsize=FIGSIZE)
|
| 145 |
+
|
| 146 |
+
levels = sorted(df["level"].unique())
|
| 147 |
+
for level in levels:
|
| 148 |
+
subset = df[df["level"] == level].copy()
|
| 149 |
+
subset = subset.sort_values("episode").reset_index(drop=True)
|
| 150 |
+
rate = subset["success"].rolling(window, min_periods=1).mean()
|
| 151 |
+
color = LEVEL_COLORS.get(int(level), "gray")
|
| 152 |
+
label = f"Level {int(level)}: {LEVEL_NAMES.get(int(level), 'unknown')}"
|
| 153 |
+
ax.plot(subset["episode"], rate, color=color, linewidth=2, label=label)
|
| 154 |
+
|
| 155 |
+
ax.axhline(0.60, color="red", linewidth=1.0, linestyle="--", alpha=0.6,
|
| 156 |
+
label="Advancement threshold (60%)")
|
| 157 |
+
ax.set_xlabel("Episode")
|
| 158 |
+
ax.set_ylabel(f"Success rate (rolling mean, window={window})")
|
| 159 |
+
ax.set_title("Success Rate per Curriculum Level")
|
| 160 |
+
ax.set_ylim(0, 1.05)
|
| 161 |
+
ax.legend(loc="lower right")
|
| 162 |
+
ax.grid(True)
|
| 163 |
+
fig.tight_layout()
|
| 164 |
+
|
| 165 |
+
out_path = out_dir / "success_rate.png"
|
| 166 |
+
fig.savefig(out_path, dpi=DPI)
|
| 167 |
+
plt.close(fig)
|
| 168 |
+
print(f"[plot_rewards] Saved: {out_path}")
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def plot_accuracy_gain(df: pd.DataFrame, out_dir: Path, window: int = 20):
|
| 172 |
+
"""Plot 3: Accuracy gain over training."""
|
| 173 |
+
fig, ax = plt.subplots(figsize=FIGSIZE)
|
| 174 |
+
|
| 175 |
+
ax.plot(df["episode"], df["accuracy_gain"], alpha=0.25, color="green",
|
| 176 |
+
linewidth=0.8, label="Raw accuracy gain")
|
| 177 |
+
|
| 178 |
+
if len(df) >= window:
|
| 179 |
+
smooth = df["accuracy_gain"].rolling(window, min_periods=1).mean()
|
| 180 |
+
ax.plot(df["episode"], smooth, color="green", linewidth=2.2,
|
| 181 |
+
label=f"Rolling mean (window={window})")
|
| 182 |
+
|
| 183 |
+
ax.axhline(0, color="black", linewidth=0.8, linestyle="-", alpha=0.4)
|
| 184 |
+
ax.set_xlabel("Episode")
|
| 185 |
+
ax.set_ylabel("Accuracy gain vs baseline")
|
| 186 |
+
ax.set_title("Accuracy Gain per Episode")
|
| 187 |
+
ax.legend(loc="lower right")
|
| 188 |
+
ax.grid(True)
|
| 189 |
+
fig.tight_layout()
|
| 190 |
+
|
| 191 |
+
out_path = out_dir / "accuracy_gain.png"
|
| 192 |
+
fig.savefig(out_path, dpi=DPI)
|
| 193 |
+
plt.close(fig)
|
| 194 |
+
print(f"[plot_rewards] Saved: {out_path}")
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def plot_curriculum(df: pd.DataFrame, out_dir: Path):
|
| 198 |
+
"""Plot 4: Curriculum level progression over time."""
|
| 199 |
+
fig, ax = plt.subplots(figsize=FIGSIZE)
|
| 200 |
+
|
| 201 |
+
colors = [LEVEL_COLORS.get(int(l), "gray") for l in df["level"]]
|
| 202 |
+
ax.scatter(df["episode"], df["level"], c=colors, s=4, alpha=0.5, zorder=2)
|
| 203 |
+
|
| 204 |
+
# Smooth line
|
| 205 |
+
ax.plot(df["episode"], df["level"].rolling(10, min_periods=1).mean(),
|
| 206 |
+
color="black", linewidth=1.5, alpha=0.6, label="Rolling mean level")
|
| 207 |
+
|
| 208 |
+
ax.set_xlabel("Episode")
|
| 209 |
+
ax.set_ylabel("Curriculum level")
|
| 210 |
+
ax.set_title("Curriculum Progression")
|
| 211 |
+
ax.set_yticks([0, 1, 2, 3])
|
| 212 |
+
ax.set_yticklabels(["0: tutorial", "1: easy", "2: medium", "3: hard"])
|
| 213 |
+
ax.grid(True, axis="x")
|
| 214 |
+
|
| 215 |
+
patches = [mpatches.Patch(color=c, label=f"{l}: {LEVEL_NAMES[l]}")
|
| 216 |
+
for l, c in LEVEL_COLORS.items()]
|
| 217 |
+
ax.legend(handles=patches, loc="lower right", fontsize=9)
|
| 218 |
+
fig.tight_layout()
|
| 219 |
+
|
| 220 |
+
out_path = out_dir / "curriculum.png"
|
| 221 |
+
fig.savefig(out_path, dpi=DPI)
|
| 222 |
+
plt.close(fig)
|
| 223 |
+
print(f"[plot_rewards] Saved: {out_path}")
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
# ── Entry point ───────────────────────────────────────────────────────────────
|
| 227 |
+
|
| 228 |
+
def plot_all(log_path: str = "logs/training.jsonl", out_dir: str = "plots/",
|
| 229 |
+
window: int = 20):
|
| 230 |
+
df = load_log(log_path)
|
| 231 |
+
if df.empty:
|
| 232 |
+
print("[plot_rewards] No data to plot.")
|
| 233 |
+
return
|
| 234 |
+
|
| 235 |
+
out = Path(out_dir)
|
| 236 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 237 |
+
|
| 238 |
+
plot_reward_curve(df, out, window)
|
| 239 |
+
plot_success_rate(df, out, window)
|
| 240 |
+
plot_accuracy_gain(df, out, window)
|
| 241 |
+
plot_curriculum(df, out)
|
| 242 |
+
|
| 243 |
+
print(f"\n[plot_rewards] All plots saved to {out}/")
|
| 244 |
+
print(f" Episodes: {len(df)} | "
|
| 245 |
+
f"Avg reward: {df['reward'].mean():.3f} | "
|
| 246 |
+
f"Success rate: {df['success'].mean():.1%} | "
|
| 247 |
+
f"Max level reached: {int(df['level'].max())}")
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
if __name__ == "__main__":
|
| 251 |
+
parser = argparse.ArgumentParser(description="Plot training reward curves")
|
| 252 |
+
parser.add_argument("--log", default="logs/training.jsonl",
|
| 253 |
+
help="Path to JSONL training log")
|
| 254 |
+
parser.add_argument("--out", default="plots/",
|
| 255 |
+
help="Output directory for plots")
|
| 256 |
+
parser.add_argument("--window", type=int, default=20,
|
| 257 |
+
help="Rolling mean window size")
|
| 258 |
+
args = parser.parse_args()
|
| 259 |
+
plot_all(args.log, args.out, args.window)
|
plots/.gitkeep
ADDED
|
File without changes
|
pyproject.toml
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
[build-system]
|
| 8 |
+
requires = ["setuptools>=45", "wheel"]
|
| 9 |
+
build-backend = "setuptools.build_meta"
|
| 10 |
+
|
| 11 |
+
[project]
|
| 12 |
+
name = "openenv-data_centric_env"
|
| 13 |
+
version = "0.1.0"
|
| 14 |
+
description = "Data Centric Env environment for OpenEnv"
|
| 15 |
+
requires-python = ">=3.10"
|
| 16 |
+
dependencies = [
|
| 17 |
+
# Core OpenEnv runtime (provides FastAPI server + HTTP client types)
|
| 18 |
+
"openenv-core[core]>=0.2.2",
|
| 19 |
+
# Environment-specific dependencies
|
| 20 |
+
"scikit-learn>=1.3.0",
|
| 21 |
+
"pandas>=2.0.0",
|
| 22 |
+
"numpy>=1.24.0",
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
[project.optional-dependencies]
|
| 26 |
+
dev = [
|
| 27 |
+
"pytest>=8.0.0",
|
| 28 |
+
"pytest-cov>=4.0.0",
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
[project.scripts]
|
| 32 |
+
# Server entry point - enables running via: uv run --project . server
|
| 33 |
+
# or: python -m data_centric_env.server.app
|
| 34 |
+
server = "data_centric_env.server.app:main"
|
| 35 |
+
|
| 36 |
+
[tool.setuptools]
|
| 37 |
+
include-package-data = true
|
| 38 |
+
packages = ["data_centric_env", "data_centric_env.server"]
|
| 39 |
+
package-dir = { "data_centric_env" = ".", "data_centric_env.server" = "server" }
|
| 40 |
+
|
| 41 |
+
[tool.pytest.ini_options]
|
| 42 |
+
testpaths = ["tests"]
|
| 43 |
+
pythonpath = ["."]
|
server/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""Data-Centric AI RL environment server components."""
|
| 8 |
+
|
| 9 |
+
from .data_centric_environment import DataCentricEnvironment
|
| 10 |
+
|
| 11 |
+
__all__ = ["DataCentricEnvironment"]
|
server/anti_exploit.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Anti-Exploit Protections for Data-Centric RL Environment.
|
| 3 |
+
|
| 4 |
+
Centralised module for all anti-hacking checks:
|
| 5 |
+
1. Input truncation (>200 chars → truncate, -0.02 penalty)
|
| 6 |
+
2. Validate spam prevention (cooldown + diminishing returns)
|
| 7 |
+
3. Recommendation ID staleness check
|
| 8 |
+
4. Ground truth immutability assertion
|
| 9 |
+
5. Catastrophic data loss detection
|
| 10 |
+
6. Duplicate apply prevention
|
| 11 |
+
7. Max applies per session (3)
|
| 12 |
+
8. Episode wall-clock timeout (5 min → forced submit, -0.10)
|
| 13 |
+
9. Step timeout (5 sec → timeout obs, -0.05)
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import logging
|
| 17 |
+
import time
|
| 18 |
+
from dataclasses import dataclass, field
|
| 19 |
+
from typing import Optional, Set
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
MAX_ACTION_CHARS = 200
|
| 24 |
+
MAX_APPLIES_PER_SESSION = 3
|
| 25 |
+
FREE_VALIDATES = 3
|
| 26 |
+
VALIDATE_COOLDOWN = 2 # must take this many non-validate actions before next validate
|
| 27 |
+
EPISODE_TIMEOUT_SECS = 5 * 60 # 5 minutes
|
| 28 |
+
STEP_TIMEOUT_SECS = 5 # 5 seconds per step
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# ── Exploit tracker (per episode state) ──────────────────────────────────────
|
| 32 |
+
|
| 33 |
+
@dataclass
|
| 34 |
+
class AntiExploitState:
|
| 35 |
+
# Validate tracking
|
| 36 |
+
validate_call_count: int = 0
|
| 37 |
+
steps_since_last_validate: int = 0 # cooldown counter
|
| 38 |
+
|
| 39 |
+
# Apply tracking
|
| 40 |
+
applied_ids_this_session: Set[int] = field(default_factory=set)
|
| 41 |
+
applies_this_session: int = 0
|
| 42 |
+
|
| 43 |
+
# Timing
|
| 44 |
+
episode_start_time: float = field(default_factory=time.time)
|
| 45 |
+
|
| 46 |
+
# Ground truth row count (set at reset)
|
| 47 |
+
ground_truth_row_count: int = 0
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ── 1. Input truncation ───────────────────────────────────────────────────────
|
| 51 |
+
|
| 52 |
+
def check_and_truncate_input(action: str) -> tuple[str, float, bool]:
|
| 53 |
+
"""
|
| 54 |
+
Returns (truncated_action, penalty, was_truncated).
|
| 55 |
+
Penalty is -0.02 if truncated, else 0.0.
|
| 56 |
+
"""
|
| 57 |
+
if len(action) > MAX_ACTION_CHARS:
|
| 58 |
+
logger.warning(
|
| 59 |
+
"Input truncated: original length %d > %d", len(action), MAX_ACTION_CHARS
|
| 60 |
+
)
|
| 61 |
+
return action[:MAX_ACTION_CHARS], -0.02, True
|
| 62 |
+
return action, 0.0, False
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ── 2. Validate cooldown ──────────────────────────────────────────────────────
|
| 66 |
+
|
| 67 |
+
def check_validate_cooldown(state: AntiExploitState) -> tuple[bool, str]:
|
| 68 |
+
"""
|
| 69 |
+
Returns (allowed, error_message).
|
| 70 |
+
Validate is blocked if steps_since_last_validate < VALIDATE_COOLDOWN.
|
| 71 |
+
"""
|
| 72 |
+
if state.steps_since_last_validate < VALIDATE_COOLDOWN and state.validate_call_count > 0:
|
| 73 |
+
return False, (
|
| 74 |
+
f"Validate on cooldown. Take {VALIDATE_COOLDOWN - state.steps_since_last_validate} "
|
| 75 |
+
f"more action(s) before validating again."
|
| 76 |
+
)
|
| 77 |
+
return True, ""
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def get_validate_reward(state: AntiExploitState) -> float:
|
| 81 |
+
"""Returns +0.02 for first FREE_VALIDATES calls, -0.01 thereafter."""
|
| 82 |
+
if state.validate_call_count < FREE_VALIDATES:
|
| 83 |
+
return 0.02
|
| 84 |
+
return -0.01
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def record_validate(state: AntiExploitState):
|
| 88 |
+
state.validate_call_count += 1
|
| 89 |
+
state.steps_since_last_validate = 0
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def record_non_validate_step(state: AntiExploitState):
|
| 93 |
+
state.steps_since_last_validate += 1
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
# ── 3. Recommendation staleness ───────────────────────────────────────────────
|
| 97 |
+
|
| 98 |
+
def check_recommendation_staleness(
|
| 99 |
+
rec_id: int,
|
| 100 |
+
current_session_id: str,
|
| 101 |
+
recommendation_session_id: str,
|
| 102 |
+
) -> tuple[bool, str]:
|
| 103 |
+
"""Returns (is_fresh, error_message)."""
|
| 104 |
+
if current_session_id != recommendation_session_id:
|
| 105 |
+
return False, (
|
| 106 |
+
f"Stale recommendation ID {rec_id}. "
|
| 107 |
+
"Please re-query for fresh recommendations."
|
| 108 |
+
)
|
| 109 |
+
return True, ""
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# ── 4. Ground truth immutability ──────────────────────────────────────────────
|
| 113 |
+
|
| 114 |
+
def assert_ground_truth_intact(
|
| 115 |
+
ground_truth_len: int,
|
| 116 |
+
original_gt_len: int,
|
| 117 |
+
) -> tuple[bool, str]:
|
| 118 |
+
"""Asserts ground truth has not been mutated."""
|
| 119 |
+
if ground_truth_len != original_gt_len:
|
| 120 |
+
msg = (
|
| 121 |
+
f"INTEGRITY VIOLATION: ground_truth row count changed "
|
| 122 |
+
f"({original_gt_len} → {ground_truth_len}). This should never happen."
|
| 123 |
+
)
|
| 124 |
+
logger.critical(msg)
|
| 125 |
+
return False, msg
|
| 126 |
+
return True, ""
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
# ── 5. Catastrophic data loss ─────────────────────────────────────────────────
|
| 130 |
+
|
| 131 |
+
def check_catastrophic_data_loss(
|
| 132 |
+
current_rows: int,
|
| 133 |
+
original_rows: int,
|
| 134 |
+
) -> tuple[bool, str]:
|
| 135 |
+
"""Returns (is_catastrophic, message)."""
|
| 136 |
+
ratio = current_rows / max(original_rows, 1)
|
| 137 |
+
if ratio < 0.50:
|
| 138 |
+
msg = (
|
| 139 |
+
f"CATASTROPHIC DATA LOSS: only {current_rows}/{original_rows} rows remain "
|
| 140 |
+
f"({ratio*100:.1f}%). Episode terminated."
|
| 141 |
+
)
|
| 142 |
+
logger.error(msg)
|
| 143 |
+
return True, msg
|
| 144 |
+
return False, ""
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
# ── 6 & 7. Duplicate apply and session limit ──────────────────────────────────
|
| 148 |
+
|
| 149 |
+
def check_apply_allowed(
|
| 150 |
+
rec_id: int,
|
| 151 |
+
state: AntiExploitState,
|
| 152 |
+
) -> tuple[bool, str]:
|
| 153 |
+
"""
|
| 154 |
+
Returns (allowed, error_message).
|
| 155 |
+
Blocks: duplicate ID in session, or session apply limit reached.
|
| 156 |
+
"""
|
| 157 |
+
if state.applies_this_session >= MAX_APPLIES_PER_SESSION:
|
| 158 |
+
return False, (
|
| 159 |
+
f"Max {MAX_APPLIES_PER_SESSION} applies per query session reached. "
|
| 160 |
+
"Please re-query for more options."
|
| 161 |
+
)
|
| 162 |
+
if rec_id in state.applied_ids_this_session:
|
| 163 |
+
return False, (
|
| 164 |
+
f"Recommendation {rec_id} has already been applied this session. "
|
| 165 |
+
"Duplicate apply not allowed."
|
| 166 |
+
)
|
| 167 |
+
return True, ""
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def record_apply(rec_id: int, state: AntiExploitState):
|
| 171 |
+
state.applied_ids_this_session.add(rec_id)
|
| 172 |
+
state.applies_this_session += 1
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def reset_session_apply_state(state: AntiExploitState):
|
| 176 |
+
"""Call this whenever a new query_X command resets the session."""
|
| 177 |
+
state.applied_ids_this_session = set()
|
| 178 |
+
state.applies_this_session = 0
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
# ── 8. Episode timeout ────────────────────────────────────────────────────────
|
| 182 |
+
|
| 183 |
+
def check_episode_timeout(state: AntiExploitState) -> tuple[bool, str]:
|
| 184 |
+
elapsed = time.time() - state.episode_start_time
|
| 185 |
+
if elapsed > EPISODE_TIMEOUT_SECS:
|
| 186 |
+
msg = (
|
| 187 |
+
f"Episode wall-clock timeout ({elapsed:.0f}s > {EPISODE_TIMEOUT_SECS}s). "
|
| 188 |
+
"Forcing submit. Penalty: -0.10."
|
| 189 |
+
)
|
| 190 |
+
logger.warning(msg)
|
| 191 |
+
return True, msg
|
| 192 |
+
return False, ""
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
# ── 9. Step timeout context manager ──────────────────────────────────────────
|
| 196 |
+
|
| 197 |
+
class StepTimeoutError(Exception):
|
| 198 |
+
pass
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def validate_calls_remaining(state: AntiExploitState) -> int:
|
| 202 |
+
return max(0, FREE_VALIDATES - state.validate_call_count)
|
server/app.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI application for the Data Centric Env Environment."""
|
| 2 |
+
|
| 3 |
+
import sys
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
# Ensure the project root is on the path regardless of how the server is launched
|
| 7 |
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
| 8 |
+
_ROOT = os.path.dirname(_HERE)
|
| 9 |
+
if _ROOT not in sys.path:
|
| 10 |
+
sys.path.insert(0, _ROOT)
|
| 11 |
+
|
| 12 |
+
try:
|
| 13 |
+
from openenv.core.env_server.http_server import create_app
|
| 14 |
+
except Exception as e:
|
| 15 |
+
raise ImportError(
|
| 16 |
+
"openenv-core is required. Install with: pip install openenv-core"
|
| 17 |
+
) from e
|
| 18 |
+
|
| 19 |
+
try:
|
| 20 |
+
from ..models import DataCentricAction, DataCentricObservation
|
| 21 |
+
from .data_centric_environment import DataCentricEnvironment
|
| 22 |
+
except (ImportError, ModuleNotFoundError):
|
| 23 |
+
from models import DataCentricAction, DataCentricObservation
|
| 24 |
+
from server.data_centric_environment import DataCentricEnvironment
|
| 25 |
+
|
| 26 |
+
from fastapi.responses import HTMLResponse
|
| 27 |
+
|
| 28 |
+
# max_concurrent_envs=1: avoids concurrency safety check that instantiates the env
|
| 29 |
+
# at startup (which would load sklearn and pandas, slowing HF health check).
|
| 30 |
+
# Increase if running on a paid Space with more RAM.
|
| 31 |
+
app = create_app(
|
| 32 |
+
DataCentricEnvironment,
|
| 33 |
+
DataCentricAction,
|
| 34 |
+
DataCentricObservation,
|
| 35 |
+
env_name="data_centric_env",
|
| 36 |
+
max_concurrent_envs=1,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
_LANDING_HTML = """<!DOCTYPE html>
|
| 40 |
+
<html lang="en">
|
| 41 |
+
<head>
|
| 42 |
+
<meta charset="UTF-8">
|
| 43 |
+
<title>Data-Centric AI RL Environment</title>
|
| 44 |
+
<style>
|
| 45 |
+
body { font-family: system-ui, sans-serif; background: #0f1117; color: #e0e0e0;
|
| 46 |
+
display: flex; justify-content: center; padding: 60px 20px; margin: 0; }
|
| 47 |
+
.card { max-width: 700px; width: 100%; }
|
| 48 |
+
h1 { font-size: 2rem; margin-bottom: 4px; color: #fff; }
|
| 49 |
+
.badge { display:inline-block; background:#238636; color:#fff; border-radius:12px;
|
| 50 |
+
padding:2px 10px; font-size:0.8rem; margin-bottom:24px; }
|
| 51 |
+
p { color: #aaa; line-height: 1.6; }
|
| 52 |
+
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 28px 0; }
|
| 53 |
+
.endpoint { background: #1c1f26; border: 1px solid #30363d; border-radius: 8px;
|
| 54 |
+
padding: 14px 18px; }
|
| 55 |
+
.endpoint code { color: #58a6ff; font-size: 0.9rem; }
|
| 56 |
+
.endpoint small { color: #666; display:block; margin-top:4px; }
|
| 57 |
+
a { color: #58a6ff; text-decoration: none; }
|
| 58 |
+
a:hover { text-decoration: underline; }
|
| 59 |
+
.footer { margin-top: 32px; font-size: 0.8rem; color: #555; border-top: 1px solid #21262d; padding-top: 16px; }
|
| 60 |
+
</style>
|
| 61 |
+
</head>
|
| 62 |
+
<body>
|
| 63 |
+
<div class="card">
|
| 64 |
+
<h1>🧠 Data-Centric AI Environment</h1>
|
| 65 |
+
<span class="badge">• Running</span>
|
| 66 |
+
<p>An <a href="https://github.com/meta-pytorch/OpenEnv" target="_blank">OpenEnv</a>-compliant
|
| 67 |
+
RL environment that trains an LLM to coordinate specialist data-cleaning agents
|
| 68 |
+
to improve a frozen ML classifier — without touching the model.</p>
|
| 69 |
+
|
| 70 |
+
<div class="grid">
|
| 71 |
+
<div class="endpoint">
|
| 72 |
+
<code>GET /health</code>
|
| 73 |
+
<small>Server health check</small>
|
| 74 |
+
</div>
|
| 75 |
+
<div class="endpoint">
|
| 76 |
+
<code>GET /docs</code>
|
| 77 |
+
<small>Interactive API docs (Swagger)</small>
|
| 78 |
+
</div>
|
| 79 |
+
<div class="endpoint">
|
| 80 |
+
<code>POST /reset</code>
|
| 81 |
+
<small>Start a new episode</small>
|
| 82 |
+
</div>
|
| 83 |
+
<div class="endpoint">
|
| 84 |
+
<code>POST /step</code>
|
| 85 |
+
<small>Execute an action</small>
|
| 86 |
+
</div>
|
| 87 |
+
<div class="endpoint">
|
| 88 |
+
<code>WS /ws</code>
|
| 89 |
+
<small>WebSocket (stateful session)</small>
|
| 90 |
+
</div>
|
| 91 |
+
<div class="endpoint">
|
| 92 |
+
<code>GET /state</code>
|
| 93 |
+
<small>Current episode state</small>
|
| 94 |
+
</div>
|
| 95 |
+
</div>
|
| 96 |
+
|
| 97 |
+
<p><strong>Quick start:</strong></p>
|
| 98 |
+
<pre style="background:#161b22;padding:14px;border-radius:8px;font-size:0.85rem;overflow:auto">
|
| 99 |
+
pip install git+https://huggingface.co/spaces/Aswini-Kumar/data-centric-env
|
| 100 |
+
|
| 101 |
+
from data_centric_env import DataCentricEnv, DataCentricAction
|
| 102 |
+
with DataCentricEnv(base_url="https://aswini-kumar-data-centric-env.hf.space").sync() as env:
|
| 103 |
+
obs = env.reset(task="task_0_tutorial", seed=42)
|
| 104 |
+
result = env.step(DataCentricAction(message="query_analyst"))
|
| 105 |
+
print(result.observation.response)</pre>
|
| 106 |
+
|
| 107 |
+
<div class="footer">
|
| 108 |
+
<a href="/docs">API Docs</a> |
|
| 109 |
+
<a href="/health">Health</a> |
|
| 110 |
+
<a href="https://github.com/CelestialWorthyOfHeavenAndEarth/data-centric-env" target="_blank">GitHub</a> |
|
| 111 |
+
<a href="https://huggingface.co/spaces/Aswini-Kumar/data-centric-env" target="_blank">HF Space</a>
|
| 112 |
+
</div>
|
| 113 |
+
</div>
|
| 114 |
+
</body>
|
| 115 |
+
</html>"""
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
|
| 119 |
+
@app.get("/web", response_class=HTMLResponse, include_in_schema=False)
|
| 120 |
+
async def landing():
|
| 121 |
+
"""Human-readable landing page for the HF Space App tab."""
|
| 122 |
+
return HTMLResponse(content=_LANDING_HTML)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def main(host: str = "0.0.0.0", port: int = 7860):
|
| 126 |
+
import uvicorn
|
| 127 |
+
uvicorn.run(app, host=host, port=port)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
if __name__ == "__main__":
|
| 131 |
+
import argparse
|
| 132 |
+
parser = argparse.ArgumentParser()
|
| 133 |
+
parser.add_argument("--host", default="0.0.0.0")
|
| 134 |
+
parser.add_argument("--port", type=int, default=7860)
|
| 135 |
+
args = parser.parse_args()
|
| 136 |
+
main(host=args.host, port=args.port)
|
server/data_centric_environment.py
ADDED
|
@@ -0,0 +1,727 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Main DataCentric RL Environment."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import time
|
| 5 |
+
from copy import deepcopy
|
| 6 |
+
from typing import Any, Dict, List, Optional
|
| 7 |
+
from uuid import uuid4
|
| 8 |
+
|
| 9 |
+
import pandas as pd
|
| 10 |
+
from openenv.core.env_server.interfaces import Environment
|
| 11 |
+
from openenv.core.env_server.types import State
|
| 12 |
+
|
| 13 |
+
try:
|
| 14 |
+
from ..models import DataCentricAction, DataCentricObservation
|
| 15 |
+
except ImportError:
|
| 16 |
+
try:
|
| 17 |
+
from models import DataCentricAction, DataCentricObservation
|
| 18 |
+
except ImportError:
|
| 19 |
+
import sys as _sys, os as _os
|
| 20 |
+
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
|
| 21 |
+
from models import DataCentricAction, DataCentricObservation
|
| 22 |
+
|
| 23 |
+
try:
|
| 24 |
+
from .anti_exploit import (
|
| 25 |
+
AntiExploitState, assert_ground_truth_intact,
|
| 26 |
+
check_and_truncate_input, check_apply_allowed,
|
| 27 |
+
check_catastrophic_data_loss, check_episode_timeout,
|
| 28 |
+
check_validate_cooldown, get_validate_reward, record_apply,
|
| 29 |
+
record_non_validate_step, record_validate, reset_session_apply_state,
|
| 30 |
+
validate_calls_remaining,
|
| 31 |
+
)
|
| 32 |
+
from .dataset_generator import TASK_CONFIGS, generate_dataset
|
| 33 |
+
from .grader import (
|
| 34 |
+
compute_accuracy_reward, compute_efficiency_reward,
|
| 35 |
+
compute_lightweight_score, compute_preservation_reward,
|
| 36 |
+
compute_process_reward, compute_step_reward, compute_total_reward,
|
| 37 |
+
)
|
| 38 |
+
from .model_evaluator import ModelEvaluator
|
| 39 |
+
from .specialist_agents import (
|
| 40 |
+
AugmenterAgent, AnalystAgent, BalancerAgent, CleanerAgent,
|
| 41 |
+
SessionRegistry, ValidatorAgent, compute_drift, format_drift_summary,
|
| 42 |
+
)
|
| 43 |
+
except ImportError:
|
| 44 |
+
from server.anti_exploit import (
|
| 45 |
+
AntiExploitState, assert_ground_truth_intact,
|
| 46 |
+
check_and_truncate_input, check_apply_allowed,
|
| 47 |
+
check_catastrophic_data_loss, check_episode_timeout,
|
| 48 |
+
check_validate_cooldown, get_validate_reward, record_apply,
|
| 49 |
+
record_non_validate_step, record_validate, reset_session_apply_state,
|
| 50 |
+
validate_calls_remaining,
|
| 51 |
+
)
|
| 52 |
+
from server.dataset_generator import TASK_CONFIGS, generate_dataset
|
| 53 |
+
from server.grader import (
|
| 54 |
+
compute_accuracy_reward, compute_efficiency_reward,
|
| 55 |
+
compute_lightweight_score, compute_preservation_reward,
|
| 56 |
+
compute_process_reward, compute_step_reward, compute_total_reward,
|
| 57 |
+
)
|
| 58 |
+
from server.model_evaluator import ModelEvaluator
|
| 59 |
+
from server.specialist_agents import (
|
| 60 |
+
AugmenterAgent, AnalystAgent, BalancerAgent, CleanerAgent,
|
| 61 |
+
SessionRegistry, ValidatorAgent, compute_drift, format_drift_summary,
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
logger = logging.getLogger(__name__)
|
| 65 |
+
|
| 66 |
+
AVAILABLE_COMMANDS = """Available commands:
|
| 67 |
+
inspect_dataset — shape, dtypes, missing, class distribution
|
| 68 |
+
inspect_model — accuracy (RF + LR), F1, feature importance
|
| 69 |
+
query_analyst — holistic diagnosis + prioritised action plan (costs 1 budget)
|
| 70 |
+
query_cleaner — get cleaning recommendations
|
| 71 |
+
query_augmenter [class] — get augmentation suggestions
|
| 72 |
+
query_balancer — get resampling recommendations
|
| 73 |
+
query_validator — check rule violations (costs 2 budget)
|
| 74 |
+
apply [id] — apply recommendation by ID
|
| 75 |
+
reject [id] — reject a recommendation
|
| 76 |
+
undo — revert last apply (max 3 levels)
|
| 77 |
+
validate — retrain and score (cooldown applies)
|
| 78 |
+
submit — finalize episode"""
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class DataCentricEnvironment(Environment):
|
| 82 |
+
"""Data-Centric AI RL Environment."""
|
| 83 |
+
|
| 84 |
+
SUPPORTS_CONCURRENT_SESSIONS: bool = True
|
| 85 |
+
|
| 86 |
+
def __init__(self):
|
| 87 |
+
self._state = State(episode_id=str(uuid4()), step_count=0)
|
| 88 |
+
self._ground_truth: Optional[pd.DataFrame] = None
|
| 89 |
+
self._working_copy: Optional[pd.DataFrame] = None
|
| 90 |
+
self._metadata: Dict[str, Any] = {}
|
| 91 |
+
self._action_history: List[str] = []
|
| 92 |
+
self._exploit: Optional[AntiExploitState] = None
|
| 93 |
+
# fast_mode=True: uses n_estimators=20 for training rollouts (~4x faster)
|
| 94 |
+
self._evaluator = ModelEvaluator(fast_mode=True)
|
| 95 |
+
self._session_registry = SessionRegistry()
|
| 96 |
+
self._cleaner = CleanerAgent()
|
| 97 |
+
self._augmenter = AugmenterAgent()
|
| 98 |
+
self._balancer = BalancerAgent()
|
| 99 |
+
self._validator = ValidatorAgent()
|
| 100 |
+
self._analyst = AnalystAgent()
|
| 101 |
+
self._current_accuracy: float = 0.0
|
| 102 |
+
self._previous_accuracy: float = 0.0
|
| 103 |
+
self._active_session: str = "none"
|
| 104 |
+
self._task: str = "task_0_tutorial"
|
| 105 |
+
# Snapshot stack for undo command (max 3 snapshots)
|
| 106 |
+
self._dataset_history: List[pd.DataFrame] = []
|
| 107 |
+
self._max_history: int = 3
|
| 108 |
+
|
| 109 |
+
# ── reset ────────────────────────────────────────────────────────────────
|
| 110 |
+
|
| 111 |
+
def reset(self, task: str = "task_0_tutorial", seed: int = 42) -> DataCentricObservation:
|
| 112 |
+
self._task = task if task in TASK_CONFIGS else "task_0_tutorial"
|
| 113 |
+
cfg = TASK_CONFIGS[self._task]
|
| 114 |
+
|
| 115 |
+
self._ground_truth, self._working_copy, self._metadata = generate_dataset(
|
| 116 |
+
self._task, seed=seed
|
| 117 |
+
)
|
| 118 |
+
self._state = State(episode_id=str(uuid4()), step_count=0)
|
| 119 |
+
self._action_history = []
|
| 120 |
+
self._exploit = AntiExploitState(
|
| 121 |
+
episode_start_time=time.time(),
|
| 122 |
+
ground_truth_row_count=len(self._ground_truth),
|
| 123 |
+
)
|
| 124 |
+
self._evaluator.invalidate_cache()
|
| 125 |
+
self._session_registry = SessionRegistry()
|
| 126 |
+
self._active_session = "none"
|
| 127 |
+
self._dataset_history = [] # clear snapshot stack on reset
|
| 128 |
+
reset_session_apply_state(self._exploit)
|
| 129 |
+
|
| 130 |
+
# Store episode-start missing count for quality score baseline
|
| 131 |
+
self._metadata["initial_missing"] = int(self._working_copy.isnull().sum().sum())
|
| 132 |
+
self._metadata["baseline_accuracy"] = cfg["baseline_accuracy"]
|
| 133 |
+
|
| 134 |
+
baseline = cfg["baseline_accuracy"]
|
| 135 |
+
self._current_accuracy = baseline
|
| 136 |
+
self._previous_accuracy = baseline
|
| 137 |
+
quality = compute_lightweight_score(
|
| 138 |
+
self._working_copy, self._ground_truth,
|
| 139 |
+
self._metadata["original_length"], self._metadata["col_meta"],
|
| 140 |
+
initial_missing=self._metadata["initial_missing"],
|
| 141 |
+
)
|
| 142 |
+
wc = self._working_copy
|
| 143 |
+
return DataCentricObservation(
|
| 144 |
+
response=(
|
| 145 |
+
f"Episode started: {self._task}\n"
|
| 146 |
+
f"Baseline accuracy: {baseline:.4f} | Target: {cfg['target_accuracy']:.4f}\n"
|
| 147 |
+
f"Dataset: {len(wc)} rows × {len(wc.columns)-1} features\n"
|
| 148 |
+
f"Budget: {cfg['budget']} steps\n\n{AVAILABLE_COMMANDS}"
|
| 149 |
+
),
|
| 150 |
+
current_accuracy=baseline,
|
| 151 |
+
baseline_accuracy=baseline,
|
| 152 |
+
target_accuracy=cfg["target_accuracy"],
|
| 153 |
+
estimated_quality=quality,
|
| 154 |
+
dataset_shape=f"{len(wc)} rows × {len(wc.columns)-1} columns",
|
| 155 |
+
rows_preserved_pct=1.0,
|
| 156 |
+
budget_remaining=cfg["budget"],
|
| 157 |
+
step_number=0,
|
| 158 |
+
max_steps=cfg["budget"],
|
| 159 |
+
active_session="none",
|
| 160 |
+
validate_calls_remaining=3,
|
| 161 |
+
done=False,
|
| 162 |
+
reward=0.0,
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
# ── step ─────────────────────────────────────────────────────────────────
|
| 166 |
+
|
| 167 |
+
def step(self, action: DataCentricAction) -> DataCentricObservation:
|
| 168 |
+
if self._working_copy is None:
|
| 169 |
+
return self._error_obs("Call reset() first.")
|
| 170 |
+
|
| 171 |
+
# Episode timeout
|
| 172 |
+
timeout, tmsg = check_episode_timeout(self._exploit)
|
| 173 |
+
if timeout:
|
| 174 |
+
return self._do_submit(penalty=-0.10, extra_msg=tmsg)
|
| 175 |
+
|
| 176 |
+
# Input truncation
|
| 177 |
+
raw_msg = action.message
|
| 178 |
+
msg, trunc_penalty, was_truncated = check_and_truncate_input(raw_msg)
|
| 179 |
+
if was_truncated:
|
| 180 |
+
logger.warning("Input truncated.")
|
| 181 |
+
|
| 182 |
+
cfg = TASK_CONFIGS[self._task]
|
| 183 |
+
self._state.step_count += 1
|
| 184 |
+
step_num = self._state.step_count
|
| 185 |
+
budget_remaining = cfg["budget"] - step_num
|
| 186 |
+
cmd_parts = msg.strip().split()
|
| 187 |
+
cmd = cmd_parts[0].lower() if cmd_parts else ""
|
| 188 |
+
|
| 189 |
+
# Out of budget → force submit
|
| 190 |
+
if budget_remaining < 0:
|
| 191 |
+
return self._do_submit(penalty=0.0, extra_msg="Budget exhausted.")
|
| 192 |
+
|
| 193 |
+
# Record action
|
| 194 |
+
self._action_history.append(msg)
|
| 195 |
+
|
| 196 |
+
# Process reward component (computed for all actions)
|
| 197 |
+
r_process = compute_process_reward(self._action_history[:-1], msg)
|
| 198 |
+
|
| 199 |
+
# Route command
|
| 200 |
+
if cmd == "inspect_dataset":
|
| 201 |
+
obs = self._cmd_inspect_dataset(step_num, budget_remaining, r_process, trunc_penalty)
|
| 202 |
+
elif cmd == "inspect_model":
|
| 203 |
+
obs = self._cmd_inspect_model(step_num, budget_remaining, r_process, trunc_penalty)
|
| 204 |
+
elif cmd == "query_cleaner":
|
| 205 |
+
obs = self._cmd_query_cleaner(step_num, budget_remaining, r_process, trunc_penalty)
|
| 206 |
+
elif cmd == "query_augmenter":
|
| 207 |
+
cls = cmd_parts[1] if len(cmd_parts) > 1 else None
|
| 208 |
+
obs = self._cmd_query_augmenter(cls, step_num, budget_remaining, r_process, trunc_penalty)
|
| 209 |
+
elif cmd == "query_balancer":
|
| 210 |
+
obs = self._cmd_query_balancer(step_num, budget_remaining, r_process, trunc_penalty)
|
| 211 |
+
elif cmd == "query_analyst":
|
| 212 |
+
obs = self._cmd_query_analyst(step_num, budget_remaining, r_process, trunc_penalty)
|
| 213 |
+
elif cmd == "query_validator":
|
| 214 |
+
obs = self._cmd_query_validator(step_num, budget_remaining, r_process, trunc_penalty)
|
| 215 |
+
elif cmd == "apply":
|
| 216 |
+
try:
|
| 217 |
+
rec_id = int(cmd_parts[1]) if len(cmd_parts) > 1 else -1
|
| 218 |
+
except ValueError:
|
| 219 |
+
rec_id = -1
|
| 220 |
+
obs = self._cmd_apply(rec_id, step_num, budget_remaining, r_process, trunc_penalty)
|
| 221 |
+
elif cmd == "reject":
|
| 222 |
+
try:
|
| 223 |
+
rec_id = int(cmd_parts[1]) if len(cmd_parts) > 1 else -1
|
| 224 |
+
except ValueError:
|
| 225 |
+
rec_id = -1
|
| 226 |
+
obs = self._cmd_reject(rec_id, step_num, budget_remaining, r_process, trunc_penalty)
|
| 227 |
+
elif cmd == "validate":
|
| 228 |
+
obs = self._cmd_validate(step_num, budget_remaining, r_process, trunc_penalty)
|
| 229 |
+
elif cmd == "submit":
|
| 230 |
+
obs = self._do_submit()
|
| 231 |
+
elif cmd == "undo":
|
| 232 |
+
obs = self._cmd_undo(step_num, budget_remaining, r_process, trunc_penalty)
|
| 233 |
+
else:
|
| 234 |
+
obs = self._unknown_cmd_obs(msg, step_num, budget_remaining, r_process + trunc_penalty)
|
| 235 |
+
|
| 236 |
+
if cmd != "validate":
|
| 237 |
+
record_non_validate_step(self._exploit)
|
| 238 |
+
|
| 239 |
+
return obs
|
| 240 |
+
|
| 241 |
+
# ── command handlers ─────────────────────────────────────────────────────
|
| 242 |
+
|
| 243 |
+
def _cmd_inspect_dataset(self, step, budget, r_process, trunc_pen) -> DataCentricObservation:
|
| 244 |
+
wc = self._working_copy
|
| 245 |
+
orig_len = self._metadata["original_length"]
|
| 246 |
+
missing = wc.isnull().sum()
|
| 247 |
+
missing_str = "\n".join(f" {c}: {v}" for c, v in missing.items() if v > 0) or " None"
|
| 248 |
+
vc = wc["target"].value_counts().sort_index()
|
| 249 |
+
class_str = ", ".join(f"class {k}: {v}" for k, v in vc.items())
|
| 250 |
+
rows_pct = len(wc) / orig_len
|
| 251 |
+
response = (
|
| 252 |
+
f"=== Dataset Inspection ===\n"
|
| 253 |
+
f"Shape: {len(wc)} rows × {len(wc.columns)-1} features\n"
|
| 254 |
+
f"Original rows: {orig_len} | Preserved: {rows_pct*100:.1f}%\n"
|
| 255 |
+
f"Duplicates: {wc.duplicated().sum()}\n"
|
| 256 |
+
f"Missing values:\n{missing_str}\n"
|
| 257 |
+
f"Class distribution: {class_str}\n"
|
| 258 |
+
f"Dtypes: {dict(wc.dtypes.astype(str))}"
|
| 259 |
+
)
|
| 260 |
+
reward = compute_total_reward(0.0, r_process, 0.0) + trunc_pen
|
| 261 |
+
return self._make_obs(response, step, budget, reward)
|
| 262 |
+
|
| 263 |
+
def _cmd_inspect_model(self, step, budget, r_process, trunc_pen) -> DataCentricObservation:
|
| 264 |
+
acc, per_class, from_cache, lr_acc = self._evaluator.evaluate(
|
| 265 |
+
self._working_copy, self._ground_truth
|
| 266 |
+
)
|
| 267 |
+
cache_label = " (cached)" if from_cache else ""
|
| 268 |
+
lines = [f"=== Model Inspection{cache_label} ===",
|
| 269 |
+
f"RF Accuracy: {acc:.4f}",
|
| 270 |
+
f"LR Accuracy: {lr_acc:.4f} (secondary — diagnostic only)"]
|
| 271 |
+
for cls, metrics in per_class.items():
|
| 272 |
+
if isinstance(metrics, dict):
|
| 273 |
+
lines.append(
|
| 274 |
+
f" Class {cls}: precision={metrics.get('precision',0):.3f} "
|
| 275 |
+
f"recall={metrics.get('recall',0):.3f} "
|
| 276 |
+
f"f1={metrics.get('f1-score',0):.3f}"
|
| 277 |
+
)
|
| 278 |
+
feat_text = self._evaluator.feature_importance_text()
|
| 279 |
+
if feat_text:
|
| 280 |
+
lines.append(feat_text)
|
| 281 |
+
response = "\n".join(lines)
|
| 282 |
+
reward = compute_total_reward(0.0, r_process, 0.0) + trunc_pen
|
| 283 |
+
return self._make_obs(response, step, budget, reward)
|
| 284 |
+
|
| 285 |
+
def _cmd_query_cleaner(self, step, budget, r_process, trunc_pen) -> DataCentricObservation:
|
| 286 |
+
reset_session_apply_state(self._exploit)
|
| 287 |
+
recs = self._cleaner.query(
|
| 288 |
+
self._working_copy, self._session_registry, self._metadata["col_meta"]
|
| 289 |
+
)
|
| 290 |
+
self._active_session = f"cleaner:{self._session_registry.current_session_id[:8]}"
|
| 291 |
+
lines = ["=== Cleaner Recommendations ==="]
|
| 292 |
+
for r in recs:
|
| 293 |
+
lines.append(
|
| 294 |
+
f"[{r.id}] {r.description}\n"
|
| 295 |
+
f" type={r.action_type} impact={r.estimated_impact:+.3f} "
|
| 296 |
+
f"confidence={r.confidence:.2f}"
|
| 297 |
+
)
|
| 298 |
+
response = "\n".join(lines) if recs else "No cleaning issues detected."
|
| 299 |
+
reward = compute_total_reward(0.0, r_process, 0.0) + trunc_pen
|
| 300 |
+
return self._make_obs(response, step, budget, reward)
|
| 301 |
+
|
| 302 |
+
def _cmd_query_augmenter(self, cls, step, budget, r_process, trunc_pen) -> DataCentricObservation:
|
| 303 |
+
reset_session_apply_state(self._exploit)
|
| 304 |
+
recs = self._augmenter.query(self._working_copy, self._session_registry, cls)
|
| 305 |
+
self._active_session = f"augmenter:{self._session_registry.current_session_id[:8]}"
|
| 306 |
+
lines = ["=== Augmenter Recommendations ==="]
|
| 307 |
+
for r in recs:
|
| 308 |
+
lines.append(
|
| 309 |
+
f"[{r.id}] {r.description}\n"
|
| 310 |
+
f" type={r.action_type} impact={r.estimated_impact:+.3f} "
|
| 311 |
+
f"confidence={r.confidence:.2f}"
|
| 312 |
+
)
|
| 313 |
+
response = "\n".join(lines) if recs else "No augmentation needed."
|
| 314 |
+
reward = compute_total_reward(0.0, r_process, 0.0) + trunc_pen
|
| 315 |
+
return self._make_obs(response, step, budget, reward)
|
| 316 |
+
|
| 317 |
+
def _cmd_query_balancer(self, step, budget, r_process, trunc_pen) -> DataCentricObservation:
|
| 318 |
+
reset_session_apply_state(self._exploit)
|
| 319 |
+
recs = self._balancer.query(self._working_copy, self._session_registry)
|
| 320 |
+
self._active_session = f"balancer:{self._session_registry.current_session_id[:8]}"
|
| 321 |
+
lines = ["=== Balancer Recommendations ==="]
|
| 322 |
+
for r in recs:
|
| 323 |
+
lines.append(
|
| 324 |
+
f"[{r.id}] {r.description}\n"
|
| 325 |
+
f" type={r.action_type} impact={r.estimated_impact:+.3f} "
|
| 326 |
+
f"confidence={r.confidence:.2f}"
|
| 327 |
+
)
|
| 328 |
+
response = "\n".join(lines) if recs else "Dataset is already balanced."
|
| 329 |
+
reward = compute_total_reward(0.0, r_process, 0.0) + trunc_pen
|
| 330 |
+
return self._make_obs(response, step, budget, reward)
|
| 331 |
+
|
| 332 |
+
def _cmd_query_analyst(self, step, budget, r_process, trunc_pen) -> DataCentricObservation:
|
| 333 |
+
"""Holistic diagnosis + prioritised action plan. Costs 1 budget."""
|
| 334 |
+
# Costs 1 extra budget step
|
| 335 |
+
self._state.step_count += 1
|
| 336 |
+
plan = self._analyst.query(
|
| 337 |
+
self._working_copy,
|
| 338 |
+
self._metadata["col_meta"],
|
| 339 |
+
self._current_accuracy,
|
| 340 |
+
TASK_CONFIGS[self._task]["target_accuracy"],
|
| 341 |
+
budget - 1,
|
| 342 |
+
)
|
| 343 |
+
response = f"=== Analyst Report (costs 1 budget) ===\n{plan}"
|
| 344 |
+
reward = compute_total_reward(0.0, r_process + 0.02, 0.0) + trunc_pen # small bonus for planning
|
| 345 |
+
budget_remaining = TASK_CONFIGS[self._task]["budget"] - self._state.step_count
|
| 346 |
+
return self._make_obs(response, step, budget_remaining, reward)
|
| 347 |
+
|
| 348 |
+
def _cmd_query_validator(self, step, budget, r_process, trunc_pen) -> DataCentricObservation:
|
| 349 |
+
# Costs 2 budget
|
| 350 |
+
self._state.step_count += 1
|
| 351 |
+
violations = self._validator.query(self._working_copy, self._metadata["col_meta"])
|
| 352 |
+
lines = ["=== Validator Report (costs 2 budget) ==="]
|
| 353 |
+
if violations:
|
| 354 |
+
for v in violations:
|
| 355 |
+
lines.append(
|
| 356 |
+
f" [{v.severity}] [{v.column}] rule={v.rule} count={v.count}\n {v.description}"
|
| 357 |
+
)
|
| 358 |
+
else:
|
| 359 |
+
lines.append(" No rule violations found.")
|
| 360 |
+
response = "\n".join(lines)
|
| 361 |
+
reward = compute_total_reward(0.0, r_process, 0.0) + trunc_pen
|
| 362 |
+
budget_remaining = TASK_CONFIGS[self._task]["budget"] - self._state.step_count
|
| 363 |
+
return self._make_obs(response, step, budget_remaining, reward)
|
| 364 |
+
|
| 365 |
+
def _cmd_apply(self, rec_id, step, budget, r_process, trunc_pen) -> DataCentricObservation:
|
| 366 |
+
if rec_id < 1:
|
| 367 |
+
# Error: return 0 reward (no penalty, no bonus)
|
| 368 |
+
return self._make_obs("Error: invalid recommendation ID.", step, budget, 0.0)
|
| 369 |
+
|
| 370 |
+
# Check apply allowed (duplicate / session limit) — 0 reward on error
|
| 371 |
+
allowed, err = check_apply_allowed(rec_id, self._exploit)
|
| 372 |
+
if not allowed:
|
| 373 |
+
return self._make_obs(f"Error: {err}", step, budget, 0.0)
|
| 374 |
+
|
| 375 |
+
# Get recommendation (staleness check) — 0 reward, no penalty
|
| 376 |
+
rec = self._session_registry.get(rec_id, self._session_registry.current_session_id)
|
| 377 |
+
if rec is None:
|
| 378 |
+
return self._make_obs(
|
| 379 |
+
f"Error: stale recommendation ID {rec_id}. Please re-query for fresh recommendations.",
|
| 380 |
+
step, budget, 0.0
|
| 381 |
+
)
|
| 382 |
+
|
| 383 |
+
# Capture quality before mutation for step reward
|
| 384 |
+
quality_before = compute_lightweight_score(
|
| 385 |
+
self._working_copy, self._ground_truth,
|
| 386 |
+
self._metadata["original_length"], self._metadata["col_meta"],
|
| 387 |
+
initial_missing=self._metadata.get("initial_missing"),
|
| 388 |
+
)
|
| 389 |
+
|
| 390 |
+
# Execute payload
|
| 391 |
+
payload = rec._payload
|
| 392 |
+
action_type = payload.get("action", "")
|
| 393 |
+
wc = self._working_copy
|
| 394 |
+
orig_len = self._metadata["original_length"]
|
| 395 |
+
pre_rows = len(wc)
|
| 396 |
+
pre_missing = int(wc.isnull().sum().sum())
|
| 397 |
+
pre_dups = int(wc.duplicated().sum())
|
| 398 |
+
|
| 399 |
+
# Save snapshot for undo before mutating
|
| 400 |
+
self._dataset_history.append(self._working_copy.copy())
|
| 401 |
+
if len(self._dataset_history) > self._max_history:
|
| 402 |
+
self._dataset_history.pop(0)
|
| 403 |
+
|
| 404 |
+
try:
|
| 405 |
+
if action_type == "fill_missing":
|
| 406 |
+
col = payload["column"]
|
| 407 |
+
strategy = payload.get("strategy", "mean") # honor smarter CleanerAgent choice
|
| 408 |
+
numeric = pd.to_numeric(wc[col], errors="coerce")
|
| 409 |
+
if strategy == "median":
|
| 410 |
+
fill_val = float(numeric.median())
|
| 411 |
+
else:
|
| 412 |
+
fill_val = float(numeric.mean())
|
| 413 |
+
wc[col] = numeric.fillna(fill_val)
|
| 414 |
+
self._working_copy = wc
|
| 415 |
+
|
| 416 |
+
elif action_type == "remove_duplicates":
|
| 417 |
+
self._working_copy = wc.drop_duplicates().reset_index(drop=True)
|
| 418 |
+
|
| 419 |
+
elif action_type == "fix_type_errors":
|
| 420 |
+
col = payload["column"]
|
| 421 |
+
numeric = pd.to_numeric(wc[col], errors="coerce")
|
| 422 |
+
mean_val = float(numeric.mean())
|
| 423 |
+
wc[col] = numeric.fillna(mean_val)
|
| 424 |
+
self._working_copy = wc
|
| 425 |
+
|
| 426 |
+
elif action_type == "augment_class":
|
| 427 |
+
cls_int = payload["class"]
|
| 428 |
+
n_synth = payload["n_synth"]
|
| 429 |
+
cls_rows = wc[wc["target"] == cls_int]
|
| 430 |
+
if len(cls_rows) > 0:
|
| 431 |
+
synth = cls_rows.sample(n=n_synth, replace=True, random_state=42)
|
| 432 |
+
noise_cols = [c for c in synth.columns if c != "target"]
|
| 433 |
+
for c in noise_cols:
|
| 434 |
+
try:
|
| 435 |
+
synth[c] = pd.to_numeric(synth[c], errors="coerce")
|
| 436 |
+
synth[c] = synth[c] + synth[c].std() * 0.1
|
| 437 |
+
except Exception:
|
| 438 |
+
pass
|
| 439 |
+
self._working_copy = pd.concat([wc, synth], ignore_index=True)
|
| 440 |
+
|
| 441 |
+
elif action_type == "oversample":
|
| 442 |
+
cls_int = payload["class"]
|
| 443 |
+
target_count = payload["target_count"]
|
| 444 |
+
cls_rows = wc[wc["target"] == cls_int]
|
| 445 |
+
n_needed = max(0, target_count - len(cls_rows))
|
| 446 |
+
if n_needed > 0:
|
| 447 |
+
extra = cls_rows.sample(n=n_needed, replace=True, random_state=42)
|
| 448 |
+
self._working_copy = pd.concat([wc, extra], ignore_index=True)
|
| 449 |
+
|
| 450 |
+
elif action_type == "undersample":
|
| 451 |
+
cls_int = payload["class"]
|
| 452 |
+
target_count = payload["target_count"]
|
| 453 |
+
cls_rows = wc[wc["target"] == cls_int]
|
| 454 |
+
if len(cls_rows) > target_count:
|
| 455 |
+
keep = cls_rows.sample(n=target_count, random_state=42)
|
| 456 |
+
other = wc[wc["target"] != cls_int]
|
| 457 |
+
self._working_copy = pd.concat([keep, other], ignore_index=True)
|
| 458 |
+
|
| 459 |
+
elif action_type == "remove_outlier_rows":
|
| 460 |
+
col = payload["column"]
|
| 461 |
+
pct = payload.get("pct", 5)
|
| 462 |
+
try:
|
| 463 |
+
numeric = pd.to_numeric(wc[col], errors="coerce")
|
| 464 |
+
threshold = float(numeric.quantile(pct / 100))
|
| 465 |
+
self._working_copy = wc[pd.to_numeric(wc[col], errors="coerce") >= threshold].reset_index(drop=True)
|
| 466 |
+
except Exception:
|
| 467 |
+
pass
|
| 468 |
+
|
| 469 |
+
except Exception as exc:
|
| 470 |
+
logger.exception("Error executing apply: %s", exc)
|
| 471 |
+
return self._make_obs(f"Error executing recommendation: {exc}", step, budget, 0.0)
|
| 472 |
+
|
| 473 |
+
record_apply(rec_id, self._exploit)
|
| 474 |
+
|
| 475 |
+
# Ground truth immutability assertion — must never change
|
| 476 |
+
gt_ok, gt_msg = assert_ground_truth_intact(
|
| 477 |
+
len(self._ground_truth), self._exploit.ground_truth_row_count
|
| 478 |
+
)
|
| 479 |
+
if not gt_ok:
|
| 480 |
+
logger.critical(gt_msg)
|
| 481 |
+
return self._do_submit(penalty=-1.0, extra_msg=gt_msg)
|
| 482 |
+
|
| 483 |
+
wc_new = self._working_copy
|
| 484 |
+
post_rows = len(wc_new)
|
| 485 |
+
post_missing = int(wc_new.isnull().sum().sum())
|
| 486 |
+
post_dups = int(wc_new.duplicated().sum())
|
| 487 |
+
rows_pct = post_rows / orig_len
|
| 488 |
+
|
| 489 |
+
# Catastrophic data loss
|
| 490 |
+
catastro, cmsg = check_catastrophic_data_loss(post_rows, orig_len)
|
| 491 |
+
if catastro:
|
| 492 |
+
return self._do_submit(penalty=-0.40, extra_msg=cmsg)
|
| 493 |
+
|
| 494 |
+
# Preservation reward
|
| 495 |
+
r_preservation = compute_preservation_reward(post_rows, orig_len)
|
| 496 |
+
|
| 497 |
+
# Lightweight quality (use episode-start missing count as denominator)
|
| 498 |
+
quality = compute_lightweight_score(
|
| 499 |
+
wc_new, self._ground_truth, orig_len, self._metadata["col_meta"],
|
| 500 |
+
initial_missing=self._metadata.get("initial_missing"),
|
| 501 |
+
)
|
| 502 |
+
|
| 503 |
+
# Build rich feedback with drift detection
|
| 504 |
+
cfg = TASK_CONFIGS[self._task]
|
| 505 |
+
missing_status = "OK" if post_missing == 0 else f"{post_missing} remaining"
|
| 506 |
+
dup_status = "OK" if post_dups == 0 else f"{post_dups} remaining"
|
| 507 |
+
drift = compute_drift(self._working_copy, self._ground_truth)
|
| 508 |
+
drift_summary = format_drift_summary(drift)
|
| 509 |
+
response = (
|
| 510 |
+
f"Applied: {action_type} [{rec.description[:80]}]\n\n"
|
| 511 |
+
f"Dataset health check:\n"
|
| 512 |
+
f" Missing values: {missing_status} (was {pre_missing})\n"
|
| 513 |
+
f" Duplicates: {dup_status} (was {pre_dups})\n"
|
| 514 |
+
f" Row count: {post_rows}/{orig_len} ({rows_pct*100:.1f}% preserved)\n"
|
| 515 |
+
f" {drift_summary}\n\n"
|
| 516 |
+
f"Estimated quality score: {quality:.4f}\n"
|
| 517 |
+
f"Budget remaining: {budget}"
|
| 518 |
+
)
|
| 519 |
+
|
| 520 |
+
reward = compute_total_reward(
|
| 521 |
+
0.0, r_process, r_preservation,
|
| 522 |
+
reward_step=compute_step_reward(
|
| 523 |
+
f"apply {rec_id}", quality_before, quality, rows_pct
|
| 524 |
+
),
|
| 525 |
+
) + trunc_pen
|
| 526 |
+
self._evaluator.invalidate_cache()
|
| 527 |
+
return self._make_obs(response, step, budget, reward, quality=quality,
|
| 528 |
+
rows_pct=rows_pct)
|
| 529 |
+
|
| 530 |
+
def _cmd_reject(self, rec_id, step, budget, r_process, trunc_pen) -> DataCentricObservation:
|
| 531 |
+
response = (
|
| 532 |
+
f"Recommendation {rec_id} rejected. It will not appear in future queries."
|
| 533 |
+
if rec_id >= 1 else "Error: invalid recommendation ID."
|
| 534 |
+
)
|
| 535 |
+
reward = compute_total_reward(0.0, r_process + 0.01, 0.0) + trunc_pen
|
| 536 |
+
return self._make_obs(response, step, budget, reward)
|
| 537 |
+
|
| 538 |
+
def _cmd_undo(self, step, budget, r_process, trunc_pen) -> DataCentricObservation:
|
| 539 |
+
"""Restore previous dataset state (max 3 levels deep)."""
|
| 540 |
+
if self._dataset_history:
|
| 541 |
+
self._working_copy = self._dataset_history.pop()
|
| 542 |
+
self._evaluator.invalidate_cache()
|
| 543 |
+
orig_len = self._metadata["original_length"]
|
| 544 |
+
rows_pct = len(self._working_copy) / orig_len
|
| 545 |
+
quality = compute_lightweight_score(
|
| 546 |
+
self._working_copy, self._ground_truth,
|
| 547 |
+
orig_len, self._metadata["col_meta"],
|
| 548 |
+
initial_missing=self._metadata.get("initial_missing"),
|
| 549 |
+
)
|
| 550 |
+
response = (
|
| 551 |
+
f"Undo successful. Reverted to previous dataset state.\n"
|
| 552 |
+
f"Row count: {len(self._working_copy)}/{orig_len} ({rows_pct*100:.1f}% preserved)\n"
|
| 553 |
+
f"Estimated quality: {quality:.4f}\n"
|
| 554 |
+
f"Snapshots remaining: {len(self._dataset_history)}"
|
| 555 |
+
)
|
| 556 |
+
reward = compute_total_reward(0.0, r_process - 0.03, 0.0) + trunc_pen # small cost
|
| 557 |
+
else:
|
| 558 |
+
response = "Nothing to undo. No previous state available."
|
| 559 |
+
reward = compute_total_reward(0.0, r_process - 0.05, 0.0) + trunc_pen # larger cost
|
| 560 |
+
return self._make_obs(response, step, budget, reward)
|
| 561 |
+
|
| 562 |
+
|
| 563 |
+
def _cmd_validate(self, step, budget, r_process, trunc_pen) -> DataCentricObservation:
|
| 564 |
+
allowed, cooldown_msg = check_validate_cooldown(self._exploit)
|
| 565 |
+
if not allowed:
|
| 566 |
+
return self._make_obs(cooldown_msg, step, budget, 0.0)
|
| 567 |
+
|
| 568 |
+
prev_rf = self._evaluator.last_accuracy
|
| 569 |
+
prev_lr = self._evaluator.last_lr_accuracy
|
| 570 |
+
|
| 571 |
+
acc, per_class, from_cache, lr_acc = self._evaluator.evaluate(
|
| 572 |
+
self._working_copy, self._ground_truth
|
| 573 |
+
)
|
| 574 |
+
cache_label = " (cached)" if from_cache else ""
|
| 575 |
+
|
| 576 |
+
if from_cache:
|
| 577 |
+
r_validate = 0.0
|
| 578 |
+
else:
|
| 579 |
+
r_validate = get_validate_reward(self._exploit)
|
| 580 |
+
record_validate(self._exploit)
|
| 581 |
+
|
| 582 |
+
r_accuracy = compute_accuracy_reward(
|
| 583 |
+
acc, self._current_accuracy,
|
| 584 |
+
self._metadata["baseline_accuracy"],
|
| 585 |
+
TASK_CONFIGS[self._task]["target_accuracy"],
|
| 586 |
+
)
|
| 587 |
+
self._previous_accuracy = self._current_accuracy
|
| 588 |
+
self._current_accuracy = acc
|
| 589 |
+
|
| 590 |
+
target = TASK_CONFIGS[self._task]["target_accuracy"]
|
| 591 |
+
agreement = self._evaluator.agreement_signal(acc, lr_acc, prev_rf, prev_lr)
|
| 592 |
+
feat_text = self._evaluator.feature_importance_text()
|
| 593 |
+
|
| 594 |
+
lines = [
|
| 595 |
+
f"=== Validate{cache_label} ===",
|
| 596 |
+
f"RF Accuracy: {acc:.4f} (primary)",
|
| 597 |
+
f"LR Accuracy: {lr_acc:.4f} (secondary)",
|
| 598 |
+
f"Agreement: {agreement}",
|
| 599 |
+
]
|
| 600 |
+
for cls, metrics in per_class.items():
|
| 601 |
+
if isinstance(metrics, dict):
|
| 602 |
+
lines.append(
|
| 603 |
+
f" Class {cls}: p={metrics.get('precision',0):.3f} "
|
| 604 |
+
f"r={metrics.get('recall',0):.3f} f1={metrics.get('f1-score',0):.3f}"
|
| 605 |
+
)
|
| 606 |
+
lines.append(f"Target: {target:.4f} | {'HIT ✓' if acc >= target else 'Not yet'}")
|
| 607 |
+
if feat_text:
|
| 608 |
+
lines.append(feat_text)
|
| 609 |
+
response = "\n".join(lines)
|
| 610 |
+
|
| 611 |
+
reward = compute_total_reward(r_accuracy, r_process + r_validate, 0.0) + trunc_pen
|
| 612 |
+
return self._make_obs(response, step, budget, reward)
|
| 613 |
+
|
| 614 |
+
# ── submit ────────────────────────────────────────────────────────────────
|
| 615 |
+
|
| 616 |
+
def _do_submit(self, penalty: float = 0.0, extra_msg: str = "") -> DataCentricObservation:
|
| 617 |
+
cfg = TASK_CONFIGS[self._task]
|
| 618 |
+
orig_len = self._metadata["original_length"]
|
| 619 |
+
budget_remaining = cfg["budget"] - self._state.step_count
|
| 620 |
+
|
| 621 |
+
# Final accuracy
|
| 622 |
+
acc, per_class, _, lr_acc = self._evaluator.evaluate(
|
| 623 |
+
self._working_copy, self._ground_truth
|
| 624 |
+
)
|
| 625 |
+
self._current_accuracy = acc
|
| 626 |
+
|
| 627 |
+
r_accuracy = compute_accuracy_reward(
|
| 628 |
+
acc, self._previous_accuracy,
|
| 629 |
+
cfg["baseline_accuracy"], cfg["target_accuracy"],
|
| 630 |
+
is_submit=True,
|
| 631 |
+
)
|
| 632 |
+
r_process = compute_process_reward(self._action_history[:-1], "submit")
|
| 633 |
+
r_preservation = compute_preservation_reward(len(self._working_copy), orig_len)
|
| 634 |
+
r_efficiency = compute_efficiency_reward(
|
| 635 |
+
acc, cfg["baseline_accuracy"], cfg["budget"], max(budget_remaining, 0)
|
| 636 |
+
)
|
| 637 |
+
|
| 638 |
+
total = compute_total_reward(r_accuracy, r_process, r_preservation, r_efficiency)
|
| 639 |
+
total += penalty
|
| 640 |
+
|
| 641 |
+
hit = acc >= cfg["target_accuracy"]
|
| 642 |
+
response = (
|
| 643 |
+
f"{'=' * 40}\n"
|
| 644 |
+
f"EPISODE COMPLETE\n"
|
| 645 |
+
f"{'=' * 40}\n"
|
| 646 |
+
f"Final accuracy: {acc:.4f}\n"
|
| 647 |
+
f"Target accuracy: {cfg['target_accuracy']:.4f}\n"
|
| 648 |
+
f"Baseline: {cfg['baseline_accuracy']:.4f}\n"
|
| 649 |
+
f"Result: {'TARGET HIT ✓' if hit else 'Target not reached'}\n\n"
|
| 650 |
+
f"Reward breakdown:\n"
|
| 651 |
+
f" Accuracy: {r_accuracy:+.4f}\n"
|
| 652 |
+
f" Process: {r_process:+.4f}\n"
|
| 653 |
+
f" Preservation: {r_preservation:+.4f}\n"
|
| 654 |
+
f" Efficiency: {r_efficiency:+.4f}\n"
|
| 655 |
+
f" Penalty: {penalty:+.4f}\n"
|
| 656 |
+
f" TOTAL: {total:+.4f}\n"
|
| 657 |
+
+ (f"\n{extra_msg}" if extra_msg else "")
|
| 658 |
+
)
|
| 659 |
+
|
| 660 |
+
quality = compute_lightweight_score(
|
| 661 |
+
self._working_copy, self._ground_truth,
|
| 662 |
+
orig_len, self._metadata["col_meta"],
|
| 663 |
+
)
|
| 664 |
+
rows_pct = len(self._working_copy) / orig_len
|
| 665 |
+
|
| 666 |
+
return DataCentricObservation(
|
| 667 |
+
response=response,
|
| 668 |
+
current_accuracy=acc,
|
| 669 |
+
baseline_accuracy=cfg["baseline_accuracy"],
|
| 670 |
+
target_accuracy=cfg["target_accuracy"],
|
| 671 |
+
estimated_quality=quality,
|
| 672 |
+
dataset_shape=f"{len(self._working_copy)} rows × {len(self._working_copy.columns)-1} columns",
|
| 673 |
+
rows_preserved_pct=rows_pct,
|
| 674 |
+
budget_remaining=max(budget_remaining, 0),
|
| 675 |
+
step_number=self._state.step_count,
|
| 676 |
+
max_steps=cfg["budget"],
|
| 677 |
+
active_session=self._active_session,
|
| 678 |
+
validate_calls_remaining=validate_calls_remaining(self._exploit),
|
| 679 |
+
done=True,
|
| 680 |
+
reward=round(total, 4),
|
| 681 |
+
)
|
| 682 |
+
|
| 683 |
+
# ── helpers ───────────────────────────────────────────────────────────────
|
| 684 |
+
|
| 685 |
+
def _make_obs(self, response: str, step: int, budget: int, reward: float,
|
| 686 |
+
quality: Optional[float] = None, rows_pct: Optional[float] = None
|
| 687 |
+
) -> DataCentricObservation:
|
| 688 |
+
cfg = TASK_CONFIGS[self._task]
|
| 689 |
+
orig_len = self._metadata["original_length"]
|
| 690 |
+
wc = self._working_copy
|
| 691 |
+
if quality is None:
|
| 692 |
+
quality = compute_lightweight_score(
|
| 693 |
+
wc, self._ground_truth, orig_len, self._metadata["col_meta"],
|
| 694 |
+
initial_missing=self._metadata.get("initial_missing"),
|
| 695 |
+
)
|
| 696 |
+
if rows_pct is None:
|
| 697 |
+
rows_pct = len(wc) / orig_len
|
| 698 |
+
|
| 699 |
+
return DataCentricObservation(
|
| 700 |
+
response=response,
|
| 701 |
+
current_accuracy=self._current_accuracy,
|
| 702 |
+
baseline_accuracy=cfg["baseline_accuracy"],
|
| 703 |
+
target_accuracy=cfg["target_accuracy"],
|
| 704 |
+
estimated_quality=quality,
|
| 705 |
+
dataset_shape=f"{len(wc)} rows × {len(wc.columns)-1} columns",
|
| 706 |
+
rows_preserved_pct=rows_pct,
|
| 707 |
+
budget_remaining=max(budget, 0),
|
| 708 |
+
step_number=step,
|
| 709 |
+
max_steps=cfg["budget"],
|
| 710 |
+
active_session=self._active_session,
|
| 711 |
+
validate_calls_remaining=validate_calls_remaining(self._exploit),
|
| 712 |
+
done=False,
|
| 713 |
+
reward=round(reward, 4),
|
| 714 |
+
)
|
| 715 |
+
|
| 716 |
+
def _error_obs(self, msg: str) -> DataCentricObservation:
|
| 717 |
+
return DataCentricObservation(response=msg, done=False, reward=0.0)
|
| 718 |
+
|
| 719 |
+
def _unknown_cmd_obs(self, msg: str, step: int, budget: int,
|
| 720 |
+
reward: float) -> DataCentricObservation:
|
| 721 |
+
return self._make_obs(
|
| 722 |
+
f"Unknown command: '{msg}'\n\n{AVAILABLE_COMMANDS}", step, budget, reward
|
| 723 |
+
)
|
| 724 |
+
|
| 725 |
+
@property
|
| 726 |
+
def state(self) -> State:
|
| 727 |
+
return self._state
|
server/dataset_generator.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Dataset Generator for Data-Centric RL Environment.
|
| 3 |
+
|
| 4 |
+
Generates corrupted sklearn classification datasets with known ground truth.
|
| 5 |
+
Each task has deterministic corruptions via seeded random.Random.
|
| 6 |
+
|
| 7 |
+
CRITICAL: Always produces TWO copies:
|
| 8 |
+
ground_truth → frozen, only read by grader
|
| 9 |
+
working_copy → the only thing the agent can mutate
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import random
|
| 13 |
+
from copy import deepcopy
|
| 14 |
+
from typing import Any, Dict, Tuple
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
import pandas as pd
|
| 18 |
+
from sklearn.datasets import make_classification
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# ── Column metadata schema ──────────────────────────────────────────────────
|
| 22 |
+
|
| 23 |
+
def _make_col_meta(expected_dtype: str, valid_range=None,
|
| 24 |
+
valid_categories=None, is_nullable: bool = False) -> Dict:
|
| 25 |
+
return {
|
| 26 |
+
"expected_dtype": expected_dtype,
|
| 27 |
+
"valid_range": valid_range,
|
| 28 |
+
"valid_categories": valid_categories,
|
| 29 |
+
"is_nullable": is_nullable,
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# ── Task configurations ─────────────────────────────────────────────────────
|
| 34 |
+
|
| 35 |
+
TASK_CONFIGS = {
|
| 36 |
+
"task_0_tutorial": {
|
| 37 |
+
"n_samples": 100,
|
| 38 |
+
"n_features": 4,
|
| 39 |
+
"n_classes": 2,
|
| 40 |
+
"n_informative": 3,
|
| 41 |
+
"budget": 30,
|
| 42 |
+
"target_accuracy": 0.73,
|
| 43 |
+
"baseline_accuracy": 0.62,
|
| 44 |
+
"description": "Single-issue tutorial. Fix missing values in 'age' to win.",
|
| 45 |
+
},
|
| 46 |
+
"task_1_easy": {
|
| 47 |
+
"n_samples": 200,
|
| 48 |
+
"n_features": 5,
|
| 49 |
+
"n_classes": 2,
|
| 50 |
+
"n_informative": 4,
|
| 51 |
+
"budget": 25,
|
| 52 |
+
"target_accuracy": 0.79,
|
| 53 |
+
"baseline_accuracy": 0.63,
|
| 54 |
+
"description": "Missing values + mild class imbalance.",
|
| 55 |
+
},
|
| 56 |
+
"task_2_medium": {
|
| 57 |
+
"n_samples": 500,
|
| 58 |
+
"n_features": 7,
|
| 59 |
+
"n_classes": 3,
|
| 60 |
+
"n_informative": 5,
|
| 61 |
+
"budget": 40,
|
| 62 |
+
"target_accuracy": 0.74,
|
| 63 |
+
"baseline_accuracy": 0.58,
|
| 64 |
+
"description": "Missing values, duplicates, class imbalance, type error.",
|
| 65 |
+
},
|
| 66 |
+
"task_3_hard": {
|
| 67 |
+
"n_samples": 900,
|
| 68 |
+
"n_features": 10,
|
| 69 |
+
"n_classes": 4,
|
| 70 |
+
"n_informative": 7,
|
| 71 |
+
"budget": 60,
|
| 72 |
+
"target_accuracy": 0.71,
|
| 73 |
+
"baseline_accuracy": 0.54,
|
| 74 |
+
"description": "Missing values, duplicates, imbalance, type errors, outliers, cross-column errors.",
|
| 75 |
+
},
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# ── Generic feature names ───────────────────────────────────────────────────
|
| 80 |
+
|
| 81 |
+
FEATURE_NAMES = ["age", "income", "score", "tenure", "balance",
|
| 82 |
+
"transactions", "risk_level", "credit", "spend", "savings"]
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _build_column_meta(feature_cols: list, task: str) -> Dict[str, Dict]:
|
| 86 |
+
meta = {}
|
| 87 |
+
for col in feature_cols:
|
| 88 |
+
meta[col] = _make_col_meta("float64", valid_range=(-10.0, 10.0))
|
| 89 |
+
# age gets tighter range for tutorial plausibility
|
| 90 |
+
if "age" in meta:
|
| 91 |
+
meta["age"] = _make_col_meta("float64", valid_range=(0.0, 100.0))
|
| 92 |
+
meta["target"] = _make_col_meta("int64", valid_categories=None)
|
| 93 |
+
return meta
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
# ── Core generator ──────────────────────────────────────────────────────────
|
| 97 |
+
|
| 98 |
+
def generate_dataset(task: str, seed: int = 42) -> Tuple[pd.DataFrame, pd.DataFrame, Dict[str, Any]]:
|
| 99 |
+
"""
|
| 100 |
+
Generate a corrupted dataset for the given task.
|
| 101 |
+
|
| 102 |
+
Returns:
|
| 103 |
+
ground_truth – clean DataFrame (frozen)
|
| 104 |
+
working_copy – corrupted DataFrame (agent mutates this)
|
| 105 |
+
metadata – task config + column metadata + original_length
|
| 106 |
+
"""
|
| 107 |
+
cfg = TASK_CONFIGS[task]
|
| 108 |
+
rng = random.Random(seed)
|
| 109 |
+
np_rng = np.random.RandomState(seed)
|
| 110 |
+
|
| 111 |
+
n = cfg["n_samples"]
|
| 112 |
+
n_feat = cfg["n_features"]
|
| 113 |
+
n_cls = cfg["n_classes"]
|
| 114 |
+
|
| 115 |
+
# ── Generate clean classification data ──────────────────────────────────
|
| 116 |
+
X, y = make_classification(
|
| 117 |
+
n_samples=n,
|
| 118 |
+
n_features=n_feat,
|
| 119 |
+
n_informative=cfg["n_informative"],
|
| 120 |
+
n_redundant=max(0, n_feat - cfg["n_informative"] - 1),
|
| 121 |
+
n_classes=n_cls,
|
| 122 |
+
n_clusters_per_class=1,
|
| 123 |
+
weights=None,
|
| 124 |
+
random_state=seed,
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
cols = FEATURE_NAMES[:n_feat]
|
| 128 |
+
df_clean = pd.DataFrame(X, columns=cols)
|
| 129 |
+
df_clean["target"] = y
|
| 130 |
+
|
| 131 |
+
# Rescale 'age' column to [18, 80] for plausibility
|
| 132 |
+
if "age" in df_clean.columns:
|
| 133 |
+
mn, mx = df_clean["age"].min(), df_clean["age"].max()
|
| 134 |
+
df_clean["age"] = ((df_clean["age"] - mn) / (mx - mn + 1e-9)) * 62 + 18
|
| 135 |
+
|
| 136 |
+
ground_truth = deepcopy(df_clean)
|
| 137 |
+
working_copy = deepcopy(df_clean)
|
| 138 |
+
|
| 139 |
+
# ── Inject corruptions into working_copy only ────────────────────���───────
|
| 140 |
+
_inject_corruptions(working_copy, task, cfg, rng, np_rng, seed)
|
| 141 |
+
|
| 142 |
+
col_meta = _build_column_meta(cols, task)
|
| 143 |
+
metadata = {
|
| 144 |
+
**cfg,
|
| 145 |
+
"task": task,
|
| 146 |
+
"seed": seed,
|
| 147 |
+
"feature_cols": cols,
|
| 148 |
+
"col_meta": col_meta,
|
| 149 |
+
"original_length": len(working_copy),
|
| 150 |
+
"class_names": [str(c) for c in sorted(working_copy["target"].unique())],
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
return ground_truth, working_copy, metadata
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _inject_corruptions(df: pd.DataFrame, task: str, cfg: dict,
|
| 157 |
+
rng: random.Random, np_rng: np.random.RandomState,
|
| 158 |
+
seed: int):
|
| 159 |
+
"""Inject task-specific corruptions into df in-place."""
|
| 160 |
+
|
| 161 |
+
if task == "task_0_tutorial":
|
| 162 |
+
# Single issue: 20% missing in age only
|
| 163 |
+
_inject_missing(df, ["age"], frac=0.20, rng=rng)
|
| 164 |
+
|
| 165 |
+
elif task == "task_1_easy":
|
| 166 |
+
# Missing values 15% + mild class imbalance
|
| 167 |
+
cols = df.columns[:-1].tolist()
|
| 168 |
+
_inject_missing(df, cols[:2], frac=0.15, rng=rng)
|
| 169 |
+
_inject_class_imbalance(df, ratio=0.60, rng=rng, seed=seed)
|
| 170 |
+
|
| 171 |
+
elif task == "task_2_medium":
|
| 172 |
+
cols = df.columns[:-1].tolist()
|
| 173 |
+
_inject_missing(df, cols[:3], frac=0.12, rng=rng)
|
| 174 |
+
_inject_duplicates(df, frac=0.05, rng=rng)
|
| 175 |
+
_inject_class_imbalance(df, ratio=0.55, rng=rng, seed=seed)
|
| 176 |
+
_inject_type_error(df, cols[0], rng=rng, frac=0.04)
|
| 177 |
+
|
| 178 |
+
elif task == "task_3_hard":
|
| 179 |
+
cols = df.columns[:-1].tolist()
|
| 180 |
+
_inject_missing(df, cols[:4], frac=0.10, rng=rng)
|
| 181 |
+
_inject_duplicates(df, frac=0.05, rng=rng)
|
| 182 |
+
_inject_class_imbalance(df, ratio=0.50, rng=rng, seed=seed)
|
| 183 |
+
_inject_type_error(df, cols[0], rng=rng, frac=0.03)
|
| 184 |
+
_inject_outliers(df, cols[1], rng=rng, frac=0.03)
|
| 185 |
+
_inject_cross_column_errors(df, cols[2], cols[3], rng=rng, frac=0.02)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _inject_missing(df: pd.DataFrame, cols: list, frac: float, rng: random.Random):
|
| 189 |
+
for col in cols:
|
| 190 |
+
if col not in df.columns:
|
| 191 |
+
continue
|
| 192 |
+
indices = rng.sample(range(len(df)), int(len(df) * frac))
|
| 193 |
+
df.loc[indices, col] = np.nan
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def _inject_duplicates(df: pd.DataFrame, frac: float, rng: random.Random):
|
| 197 |
+
n_dups = max(1, int(len(df) * frac))
|
| 198 |
+
dup_indices = rng.choices(range(len(df)), k=n_dups)
|
| 199 |
+
dups = df.iloc[dup_indices].copy()
|
| 200 |
+
new_df = pd.concat([df, dups], ignore_index=True)
|
| 201 |
+
# Mutate the caller's DataFrame in-place by clearing and re-populating
|
| 202 |
+
df.drop(df.index, inplace=True)
|
| 203 |
+
df.drop(df.columns, axis=1, inplace=True)
|
| 204 |
+
for col in new_df.columns:
|
| 205 |
+
df[col] = new_df[col].values
|
| 206 |
+
df.reset_index(drop=True, inplace=True)
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def _inject_class_imbalance(df: pd.DataFrame, ratio: float,
|
| 210 |
+
rng: random.Random, seed: int):
|
| 211 |
+
"""Make class 0 account for `ratio` of rows, drop minority excess."""
|
| 212 |
+
target_col = "target"
|
| 213 |
+
classes = df[target_col].unique()
|
| 214 |
+
if len(classes) < 2:
|
| 215 |
+
return
|
| 216 |
+
major = int(classes[0])
|
| 217 |
+
n_major = int(len(df) * ratio)
|
| 218 |
+
major_idx = df[df[target_col] == major].index.tolist()
|
| 219 |
+
if len(major_idx) > n_major:
|
| 220 |
+
drop_n = len(major_idx) - n_major
|
| 221 |
+
to_drop = rng.sample(major_idx, drop_n)
|
| 222 |
+
df.drop(to_drop, inplace=True)
|
| 223 |
+
df.reset_index(drop=True, inplace=True)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def _inject_type_error(df: pd.DataFrame, col: str, rng: random.Random, frac: float):
|
| 227 |
+
"""Replace some float values with string 'ERR' to simulate type errors."""
|
| 228 |
+
if col not in df.columns:
|
| 229 |
+
return
|
| 230 |
+
indices = rng.sample(range(len(df)), max(1, int(len(df) * frac)))
|
| 231 |
+
df[col] = df[col].astype(object)
|
| 232 |
+
for i in indices:
|
| 233 |
+
df.at[i, col] = "ERR"
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def _inject_outliers(df: pd.DataFrame, col: str, rng: random.Random, frac: float):
|
| 237 |
+
if col not in df.columns:
|
| 238 |
+
return
|
| 239 |
+
indices = rng.sample(range(len(df)), max(1, int(len(df) * frac)))
|
| 240 |
+
for i in indices:
|
| 241 |
+
df.at[i, col] = rng.choice([999.0, -999.0])
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def _inject_cross_column_errors(df: pd.DataFrame, col_a: str, col_b: str,
|
| 245 |
+
rng: random.Random, frac: float):
|
| 246 |
+
"""Make col_a < col_b for some rows (e.g. min > max violations)."""
|
| 247 |
+
if col_a not in df.columns or col_b not in df.columns:
|
| 248 |
+
return
|
| 249 |
+
indices = rng.sample(range(len(df)), max(1, int(len(df) * frac)))
|
| 250 |
+
for i in indices:
|
| 251 |
+
try:
|
| 252 |
+
a = float(df.at[i, col_a])
|
| 253 |
+
b = float(df.at[i, col_b])
|
| 254 |
+
if a >= b:
|
| 255 |
+
df.at[i, col_a], df.at[i, col_b] = b - 1.0, a + 1.0
|
| 256 |
+
except (ValueError, TypeError):
|
| 257 |
+
pass
|
server/grader.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Grader for Data-Centric RL Environment — using OpenEnv Rubric system.
|
| 3 |
+
|
| 4 |
+
Implements 4 composable Rubric subclasses (nn.Module style, auto-registered
|
| 5 |
+
as child rubrics) plus a root DataCentricRubric that aggregates them.
|
| 6 |
+
|
| 7 |
+
Rubric hierarchy:
|
| 8 |
+
DataCentricRubric
|
| 9 |
+
├── accuracy : AccuracyRubric
|
| 10 |
+
├── process : ProcessRubric
|
| 11 |
+
├── preservation : PreservationRubric
|
| 12 |
+
└── efficiency : EfficiencyRubric
|
| 13 |
+
|
| 14 |
+
Also provides StepRubric for dense per-apply proxy feedback (no classifier).
|
| 15 |
+
|
| 16 |
+
Backward-compatible: compute_*() free functions still work for existing callers.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import logging
|
| 20 |
+
from typing import Any, Dict, List, Optional
|
| 21 |
+
|
| 22 |
+
import numpy as np
|
| 23 |
+
import pandas as pd
|
| 24 |
+
|
| 25 |
+
from openenv.core.rubrics.base import Rubric
|
| 26 |
+
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
+
|
| 29 |
+
# Must match openenv.yaml reward_range — enforced by DataCentricRubric
|
| 30 |
+
REWARD_MIN: float = -1.0
|
| 31 |
+
REWARD_MAX: float = 1.0
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# ── Lightweight quality score (no sklearn) ────────────────────────────────────
|
| 35 |
+
|
| 36 |
+
def compute_lightweight_score(
|
| 37 |
+
working_copy: pd.DataFrame,
|
| 38 |
+
ground_truth: pd.DataFrame,
|
| 39 |
+
original_length: int,
|
| 40 |
+
col_meta: Dict,
|
| 41 |
+
initial_missing: int = None,
|
| 42 |
+
) -> float:
|
| 43 |
+
"""
|
| 44 |
+
Fast quality score comparing working_copy to ground_truth structure.
|
| 45 |
+
Does NOT run sklearn — used for dense per-step feedback.
|
| 46 |
+
|
| 47 |
+
Score is [0.0, 1.0] composed of:
|
| 48 |
+
- missing value reduction (40%)
|
| 49 |
+
- duplicate reduction (20%)
|
| 50 |
+
- type correctness (20%)
|
| 51 |
+
- row preservation (20%)
|
| 52 |
+
"""
|
| 53 |
+
score = 0.0
|
| 54 |
+
|
| 55 |
+
# 1. Missing value reduction
|
| 56 |
+
wc_missing = int(working_copy.isnull().sum().sum())
|
| 57 |
+
denom = initial_missing if (initial_missing is not None and initial_missing > 0) else max(wc_missing, 1)
|
| 58 |
+
missing_score = max(0.0, 1.0 - wc_missing / denom) if denom > 0 else 1.0
|
| 59 |
+
score += 0.40 * missing_score
|
| 60 |
+
|
| 61 |
+
# 2. Duplicate reduction
|
| 62 |
+
n_dups_wc = int(working_copy.duplicated().sum())
|
| 63 |
+
n_dups_gt = int(ground_truth.duplicated().sum())
|
| 64 |
+
if n_dups_gt == 0 and n_dups_wc == 0:
|
| 65 |
+
dup_score = 1.0
|
| 66 |
+
elif n_dups_gt == 0:
|
| 67 |
+
dup_score = max(0.0, 1.0 - n_dups_wc / max(len(working_copy), 1))
|
| 68 |
+
else:
|
| 69 |
+
dup_score = max(0.0, 1.0 - n_dups_wc / max(n_dups_gt, 1))
|
| 70 |
+
score += 0.20 * dup_score
|
| 71 |
+
|
| 72 |
+
# 3. Type correctness
|
| 73 |
+
type_ok, type_total = 0, 0
|
| 74 |
+
for col, meta in col_meta.items():
|
| 75 |
+
if col == "target" or col not in working_copy.columns:
|
| 76 |
+
continue
|
| 77 |
+
if meta.get("expected_dtype", "float64") in ("float64", "int64"):
|
| 78 |
+
type_total += 1
|
| 79 |
+
err_count = sum(
|
| 80 |
+
1 for val in working_copy[col].dropna()
|
| 81 |
+
if not _can_float(val)
|
| 82 |
+
)
|
| 83 |
+
if err_count == 0:
|
| 84 |
+
type_ok += 1
|
| 85 |
+
score += 0.20 * ((type_ok / type_total) if type_total > 0 else 1.0)
|
| 86 |
+
|
| 87 |
+
# 4. Row preservation
|
| 88 |
+
score += 0.20 * min(len(working_copy) / max(original_length, 1), 1.0)
|
| 89 |
+
|
| 90 |
+
return round(min(score, 1.0), 4)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _can_float(val: Any) -> bool:
|
| 94 |
+
try:
|
| 95 |
+
float(val)
|
| 96 |
+
return True
|
| 97 |
+
except (ValueError, TypeError):
|
| 98 |
+
return False
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# ── Rubric 1: Accuracy ────────────────────────────────────────────────────────
|
| 102 |
+
|
| 103 |
+
class AccuracyRubric(Rubric):
|
| 104 |
+
"""
|
| 105 |
+
Main RL signal: rewards accuracy improvement, penalises regression.
|
| 106 |
+
Adds a large terminal bonus when the agent submits and crosses the target.
|
| 107 |
+
"""
|
| 108 |
+
|
| 109 |
+
def forward(self, action: Any, observation: Any) -> float:
|
| 110 |
+
current = observation.get("current_accuracy", 0.0)
|
| 111 |
+
previous = observation.get("previous_accuracy", 0.0)
|
| 112 |
+
baseline = observation.get("baseline_accuracy", 0.0)
|
| 113 |
+
target = observation.get("target_accuracy", 0.80)
|
| 114 |
+
is_submit = str(action).strip().lower() == "submit"
|
| 115 |
+
|
| 116 |
+
improvement = current - previous
|
| 117 |
+
if improvement > 0:
|
| 118 |
+
reward = improvement * 2.5
|
| 119 |
+
elif improvement < 0:
|
| 120 |
+
reward = improvement * 2.0 # regression penalised harder
|
| 121 |
+
else:
|
| 122 |
+
reward = 0.0
|
| 123 |
+
|
| 124 |
+
if is_submit:
|
| 125 |
+
if current >= target:
|
| 126 |
+
reward += 0.50 # big terminal bonus
|
| 127 |
+
else:
|
| 128 |
+
progress_range = target - baseline
|
| 129 |
+
if progress_range > 0:
|
| 130 |
+
progress = (current - baseline) / progress_range
|
| 131 |
+
reward += max(0.0, progress) * 0.25
|
| 132 |
+
|
| 133 |
+
logger.debug("AccuracyRubric: imp=%.4f reward=%.4f submit=%s", improvement, reward, is_submit)
|
| 134 |
+
return round(reward, 4)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
# ── Rubric 2: Process ─────────────────────────────────────────────────────────
|
| 138 |
+
|
| 139 |
+
class ProcessRubric(Rubric):
|
| 140 |
+
"""
|
| 141 |
+
Rewards smart workflow patterns (inspect → query → apply → validate).
|
| 142 |
+
Penalises blind apply-without-query and submit-without-validate.
|
| 143 |
+
"""
|
| 144 |
+
|
| 145 |
+
def forward(self, action: Any, observation: Any) -> float:
|
| 146 |
+
history: List[str] = observation.get("action_history", [])
|
| 147 |
+
current_action = str(action)
|
| 148 |
+
full_history = (history + [current_action])[-5:]
|
| 149 |
+
reward = 0.0
|
| 150 |
+
|
| 151 |
+
def _cmd(a: str) -> str:
|
| 152 |
+
return a.split()[0].lower()
|
| 153 |
+
|
| 154 |
+
cmd = _cmd(current_action)
|
| 155 |
+
prev_cmds = [_cmd(h) for h in full_history[:-1][-3:]]
|
| 156 |
+
|
| 157 |
+
if cmd.startswith("query_"):
|
| 158 |
+
if "inspect_dataset" in prev_cmds or "inspect_model" in prev_cmds:
|
| 159 |
+
reward += 0.02
|
| 160 |
+
|
| 161 |
+
if cmd == "apply":
|
| 162 |
+
if any(p.startswith("query_") for p in prev_cmds):
|
| 163 |
+
reward += 0.05
|
| 164 |
+
else:
|
| 165 |
+
reward -= 0.04
|
| 166 |
+
|
| 167 |
+
if cmd == "validate" and "apply" in prev_cmds:
|
| 168 |
+
reward += 0.03
|
| 169 |
+
|
| 170 |
+
if cmd == "reject":
|
| 171 |
+
reward += 0.01
|
| 172 |
+
|
| 173 |
+
if cmd == "submit":
|
| 174 |
+
all_cmds = [_cmd(h) for h in history]
|
| 175 |
+
if "validate" not in all_cmds:
|
| 176 |
+
reward -= 0.10
|
| 177 |
+
|
| 178 |
+
logger.debug("ProcessRubric: action=%s reward=%.4f", current_action, reward)
|
| 179 |
+
return round(reward, 4)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
# ── Rubric 3: Preservation ────────────────────────────────────────────────────
|
| 183 |
+
|
| 184 |
+
class PreservationRubric(Rubric):
|
| 185 |
+
"""
|
| 186 |
+
Rewards row preservation. Independent of accuracy — prevents the agent
|
| 187 |
+
from 'cheating' by deleting rows to inflate classifier confidence.
|
| 188 |
+
"""
|
| 189 |
+
|
| 190 |
+
def forward(self, action: Any, observation: Any) -> float:
|
| 191 |
+
current_rows = observation.get("current_rows", 0)
|
| 192 |
+
original_rows = observation.get("original_rows", 1)
|
| 193 |
+
rows_preserved = current_rows / max(original_rows, 1)
|
| 194 |
+
|
| 195 |
+
if rows_preserved >= 0.90:
|
| 196 |
+
reward = 0.05
|
| 197 |
+
elif rows_preserved >= 0.80:
|
| 198 |
+
reward = 0.02
|
| 199 |
+
elif rows_preserved >= 0.70:
|
| 200 |
+
reward = 0.00
|
| 201 |
+
elif rows_preserved >= 0.50:
|
| 202 |
+
reward = -0.10
|
| 203 |
+
else:
|
| 204 |
+
reward = -0.40
|
| 205 |
+
|
| 206 |
+
logger.debug("PreservationRubric: pct=%.2f reward=%.4f", rows_preserved, reward)
|
| 207 |
+
return round(reward, 4)
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
# ── Rubric 4: Efficiency ──────────────────────────────────────────────────────
|
| 211 |
+
|
| 212 |
+
class EfficiencyRubric(Rubric):
|
| 213 |
+
"""
|
| 214 |
+
Computed ONLY at submit. Rewards high accuracy gain per budget step used.
|
| 215 |
+
Encourages the agent to be surgical rather than spray-and-pray.
|
| 216 |
+
"""
|
| 217 |
+
|
| 218 |
+
def forward(self, action: Any, observation: Any) -> float:
|
| 219 |
+
if str(action).strip().lower() != "submit":
|
| 220 |
+
return 0.0
|
| 221 |
+
|
| 222 |
+
baseline = observation.get("baseline_accuracy", 0.0)
|
| 223 |
+
current = observation.get("current_accuracy", 0.0)
|
| 224 |
+
original_budget = observation.get("original_budget", 1)
|
| 225 |
+
budget_remaining = observation.get("budget_remaining", 0)
|
| 226 |
+
budget_used = max(original_budget - budget_remaining, 1)
|
| 227 |
+
accuracy_gain = current - baseline
|
| 228 |
+
|
| 229 |
+
if accuracy_gain <= 0:
|
| 230 |
+
reward = -0.05
|
| 231 |
+
else:
|
| 232 |
+
reward = min((accuracy_gain / budget_used) * 3.0, 0.20)
|
| 233 |
+
|
| 234 |
+
logger.debug("EfficiencyRubric: gain=%.4f used=%d reward=%.4f",
|
| 235 |
+
accuracy_gain, budget_used, reward)
|
| 236 |
+
return round(reward, 4)
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
# ── Rubric 5: Step (proxy, no classifier) ────────────────────────────────────
|
| 240 |
+
|
| 241 |
+
class StepRubric(Rubric):
|
| 242 |
+
"""
|
| 243 |
+
Dense per-apply proxy reward — does NOT run the RF classifier.
|
| 244 |
+
Uses lightweight quality score delta to give feedback between validate calls.
|
| 245 |
+
Registered as a standalone rubric (not a child of DataCentricRubric)
|
| 246 |
+
because it fires on every apply step, not just at episode end.
|
| 247 |
+
"""
|
| 248 |
+
|
| 249 |
+
def forward(self, action: Any, observation: Any) -> float:
|
| 250 |
+
if not str(action).startswith("apply"):
|
| 251 |
+
return 0.0
|
| 252 |
+
|
| 253 |
+
q_before = observation.get("quality_before", 0.0)
|
| 254 |
+
q_after = observation.get("quality_after", 0.0)
|
| 255 |
+
rows_pct = observation.get("rows_preserved_after", 1.0)
|
| 256 |
+
|
| 257 |
+
r = float(np.clip((q_after - q_before) * 0.3, -0.20, 0.10))
|
| 258 |
+
|
| 259 |
+
if rows_pct >= 0.95:
|
| 260 |
+
r += 0.02
|
| 261 |
+
elif rows_pct >= 0.90:
|
| 262 |
+
r += 0.01
|
| 263 |
+
elif rows_pct < 0.80:
|
| 264 |
+
r -= 0.10
|
| 265 |
+
|
| 266 |
+
return float(np.clip(r, -0.30, 0.15))
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
# ── Root Rubric (aggregates all components) ───────────────────────────────────
|
| 270 |
+
|
| 271 |
+
class DataCentricRubric(Rubric):
|
| 272 |
+
"""
|
| 273 |
+
Root composable rubric for the Data-Centric AI environment.
|
| 274 |
+
|
| 275 |
+
Child rubrics are auto-registered (PyTorch nn.Module style):
|
| 276 |
+
rubric.accuracy → AccuracyRubric
|
| 277 |
+
rubric.process → ProcessRubric
|
| 278 |
+
rubric.preservation → PreservationRubric
|
| 279 |
+
rubric.efficiency → EfficiencyRubric
|
| 280 |
+
|
| 281 |
+
Call rubric(action, obs_dict) to get total clamped reward [-1.0, 1.0].
|
| 282 |
+
Inspect rubric.accuracy.last_score etc. for per-component breakdown.
|
| 283 |
+
"""
|
| 284 |
+
|
| 285 |
+
def __init__(self):
|
| 286 |
+
super().__init__()
|
| 287 |
+
# Assigned as attributes — auto-registered as children by Rubric.__setattr__
|
| 288 |
+
self.accuracy = AccuracyRubric()
|
| 289 |
+
self.process = ProcessRubric()
|
| 290 |
+
self.preservation = PreservationRubric()
|
| 291 |
+
self.efficiency = EfficiencyRubric()
|
| 292 |
+
|
| 293 |
+
def forward(self, action: Any, observation: Any) -> float:
|
| 294 |
+
r_acc = self.accuracy(action, observation)
|
| 295 |
+
r_proc = self.process(action, observation)
|
| 296 |
+
r_pres = self.preservation(action, observation)
|
| 297 |
+
r_eff = self.efficiency(action, observation)
|
| 298 |
+
total = r_acc + r_proc + r_pres + r_eff
|
| 299 |
+
|
| 300 |
+
clamped = float(np.clip(total, REWARD_MIN, REWARD_MAX))
|
| 301 |
+
if __debug__ and abs(clamped - total) > 1e-6:
|
| 302 |
+
logger.warning("Reward %.4f clamped → %.4f", total, clamped)
|
| 303 |
+
|
| 304 |
+
logger.info(
|
| 305 |
+
"REWARD | accuracy=%.4f process=%.4f preservation=%.4f "
|
| 306 |
+
"efficiency=%.4f TOTAL=%.4f (clamped=%.4f)",
|
| 307 |
+
r_acc, r_proc, r_pres, r_eff, total, clamped,
|
| 308 |
+
)
|
| 309 |
+
return round(clamped, 4)
|
| 310 |
+
|
| 311 |
+
def breakdown(self) -> Dict[str, Optional[float]]:
|
| 312 |
+
"""Return last_score for each child rubric — useful for logging."""
|
| 313 |
+
return {
|
| 314 |
+
"accuracy": self.accuracy.last_score,
|
| 315 |
+
"process": self.process.last_score,
|
| 316 |
+
"preservation": self.preservation.last_score,
|
| 317 |
+
"efficiency": self.efficiency.last_score,
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
# ── Singleton — reuse across episode steps ────────────────────────────────────
|
| 322 |
+
|
| 323 |
+
_rubric: Optional[DataCentricRubric] = None
|
| 324 |
+
_step_rubric: Optional[StepRubric] = None
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
def get_rubric() -> DataCentricRubric:
|
| 328 |
+
global _rubric
|
| 329 |
+
if _rubric is None:
|
| 330 |
+
_rubric = DataCentricRubric()
|
| 331 |
+
return _rubric
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def get_step_rubric() -> StepRubric:
|
| 335 |
+
global _step_rubric
|
| 336 |
+
if _step_rubric is None:
|
| 337 |
+
_step_rubric = StepRubric()
|
| 338 |
+
return _step_rubric
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
# ── Backward-compatible free functions ───────────────────────────────────────
|
| 342 |
+
# (called by data_centric_environment.py — no changes needed there)
|
| 343 |
+
|
| 344 |
+
def compute_accuracy_reward(
|
| 345 |
+
current_accuracy: float, previous_accuracy: float,
|
| 346 |
+
baseline_accuracy: float, target_accuracy: float,
|
| 347 |
+
is_submit: bool = False,
|
| 348 |
+
) -> float:
|
| 349 |
+
obs = dict(current_accuracy=current_accuracy, previous_accuracy=previous_accuracy,
|
| 350 |
+
baseline_accuracy=baseline_accuracy, target_accuracy=target_accuracy)
|
| 351 |
+
action = "submit" if is_submit else "step"
|
| 352 |
+
return get_rubric().accuracy(action, obs)
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
def compute_process_reward(action_history: List[str], current_action: str) -> float:
|
| 356 |
+
obs = dict(action_history=action_history)
|
| 357 |
+
return get_rubric().process(current_action, obs)
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
def compute_preservation_reward(current_rows: int, original_rows: int) -> float:
|
| 361 |
+
obs = dict(current_rows=current_rows, original_rows=original_rows)
|
| 362 |
+
return get_rubric().preservation("step", obs)
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def compute_efficiency_reward(
|
| 366 |
+
current_accuracy: float, baseline_accuracy: float,
|
| 367 |
+
original_budget: int, budget_remaining: int,
|
| 368 |
+
) -> float:
|
| 369 |
+
obs = dict(current_accuracy=current_accuracy, baseline_accuracy=baseline_accuracy,
|
| 370 |
+
original_budget=original_budget, budget_remaining=budget_remaining)
|
| 371 |
+
return get_rubric().efficiency("submit", obs)
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def compute_step_reward(
|
| 375 |
+
action: str, quality_before: float, quality_after: float,
|
| 376 |
+
rows_preserved_after: float,
|
| 377 |
+
) -> float:
|
| 378 |
+
obs = dict(quality_before=quality_before, quality_after=quality_after,
|
| 379 |
+
rows_preserved_after=rows_preserved_after)
|
| 380 |
+
return get_step_rubric()(action, obs)
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def compute_total_reward(
|
| 384 |
+
reward_accuracy: float,
|
| 385 |
+
reward_process: float,
|
| 386 |
+
reward_preservation: float,
|
| 387 |
+
reward_efficiency: float = 0.0,
|
| 388 |
+
reward_step: float = 0.0,
|
| 389 |
+
) -> float:
|
| 390 |
+
total = reward_accuracy + reward_process + reward_preservation + reward_efficiency + reward_step
|
| 391 |
+
clamped = float(np.clip(total, REWARD_MIN, REWARD_MAX))
|
| 392 |
+
if __debug__ and abs(clamped - total) > 1e-6:
|
| 393 |
+
logger.warning("Reward %.4f clamped → %.4f", total, clamped)
|
| 394 |
+
logger.info(
|
| 395 |
+
"REWARD BREAKDOWN: accuracy=%.4f process=%.4f preservation=%.4f "
|
| 396 |
+
"efficiency=%.4f step=%.4f TOTAL=%.4f (clamped=%.4f)",
|
| 397 |
+
reward_accuracy, reward_process, reward_preservation,
|
| 398 |
+
reward_efficiency, reward_step, total, clamped,
|
| 399 |
+
)
|
| 400 |
+
return round(clamped, 4)
|
server/model_evaluator.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Model Evaluator for Data-Centric RL Environment.
|
| 3 |
+
|
| 4 |
+
Uses sklearn RandomForestClassifier (primary) + LogisticRegression (secondary)
|
| 5 |
+
with hash-based caching. Trains on working_copy, evaluates on held-out
|
| 6 |
+
ground_truth test split.
|
| 7 |
+
|
| 8 |
+
Primary accuracy (RF) drives all rewards and grading.
|
| 9 |
+
Secondary accuracy (LR) is diagnostic — shows whether improvements generalise
|
| 10 |
+
beyond the RF decision boundary (overfitting detection).
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import hashlib
|
| 14 |
+
from typing import Dict, List, Optional, Tuple
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
import pandas as pd
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class ModelEvaluator:
|
| 21 |
+
"""Caching dual-classifier evaluator.
|
| 22 |
+
|
| 23 |
+
Args:
|
| 24 |
+
fast_mode: If True, uses n_estimators=20 (for GRPO rollouts, ~4x faster).
|
| 25 |
+
If False, uses n_estimators=100 (for final eval, more accurate).
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
def __init__(self, fast_mode: bool = False):
|
| 29 |
+
self._cache_hash: Optional[str] = None
|
| 30 |
+
self._cached_accuracy: float = 0.0
|
| 31 |
+
self._cached_per_class: Dict = {}
|
| 32 |
+
self._cached_lr_accuracy: float = 0.0
|
| 33 |
+
self._cached_feature_importance: Dict[str, float] = {}
|
| 34 |
+
self._cached = False
|
| 35 |
+
self._fast_mode = fast_mode
|
| 36 |
+
self._n_estimators = 20 if fast_mode else 100
|
| 37 |
+
|
| 38 |
+
def _compute_hash(self, df: pd.DataFrame) -> str:
|
| 39 |
+
try:
|
| 40 |
+
return hashlib.md5(
|
| 41 |
+
pd.util.hash_pandas_object(df, index=True).values.tobytes()
|
| 42 |
+
).hexdigest()
|
| 43 |
+
except Exception:
|
| 44 |
+
return hashlib.md5(df.to_json().encode()).hexdigest()
|
| 45 |
+
|
| 46 |
+
def evaluate(
|
| 47 |
+
self,
|
| 48 |
+
working_copy: pd.DataFrame,
|
| 49 |
+
ground_truth: pd.DataFrame,
|
| 50 |
+
test_size: float = 0.25,
|
| 51 |
+
seed: int = 42,
|
| 52 |
+
) -> Tuple[float, Dict, bool, float]:
|
| 53 |
+
"""
|
| 54 |
+
Train on working_copy; evaluate on held-out ground_truth test split.
|
| 55 |
+
|
| 56 |
+
Returns:
|
| 57 |
+
rf_accuracy – float (primary, used for rewards)
|
| 58 |
+
per_class – dict from classification_report
|
| 59 |
+
from_cache – True if result came from cache (no retrain)
|
| 60 |
+
lr_accuracy – float (secondary, diagnostic only)
|
| 61 |
+
"""
|
| 62 |
+
from sklearn.ensemble import RandomForestClassifier
|
| 63 |
+
from sklearn.linear_model import LogisticRegression
|
| 64 |
+
from sklearn.metrics import classification_report
|
| 65 |
+
from sklearn.model_selection import train_test_split
|
| 66 |
+
|
| 67 |
+
current_hash = self._compute_hash(working_copy)
|
| 68 |
+
|
| 69 |
+
if self._cached and current_hash == self._cache_hash:
|
| 70 |
+
return self._cached_accuracy, self._cached_per_class, True, self._cached_lr_accuracy
|
| 71 |
+
|
| 72 |
+
# Prepare train set from working_copy
|
| 73 |
+
wc = working_copy.copy()
|
| 74 |
+
wc = wc.dropna(subset=["target"])
|
| 75 |
+
for col in wc.columns:
|
| 76 |
+
if col != "target":
|
| 77 |
+
wc[col] = pd.to_numeric(wc[col], errors="coerce")
|
| 78 |
+
wc = wc.dropna()
|
| 79 |
+
|
| 80 |
+
_empty = (0.0, {}, False, 0.0)
|
| 81 |
+
if len(wc) < 10:
|
| 82 |
+
self._cache_hash = current_hash
|
| 83 |
+
self._cached_accuracy = 0.0
|
| 84 |
+
self._cached_per_class = {}
|
| 85 |
+
self._cached_lr_accuracy = 0.0
|
| 86 |
+
self._cached_feature_importance = {}
|
| 87 |
+
self._cached = True
|
| 88 |
+
return _empty
|
| 89 |
+
|
| 90 |
+
X_train = wc.drop("target", axis=1).values
|
| 91 |
+
y_train = wc["target"].astype(int).values
|
| 92 |
+
|
| 93 |
+
# Build test set from ground_truth
|
| 94 |
+
gt = ground_truth.copy().dropna()
|
| 95 |
+
for col in gt.columns:
|
| 96 |
+
if col != "target":
|
| 97 |
+
gt[col] = pd.to_numeric(gt[col], errors="coerce")
|
| 98 |
+
gt = gt.dropna()
|
| 99 |
+
|
| 100 |
+
if len(gt) < 10:
|
| 101 |
+
self._cache_hash = current_hash
|
| 102 |
+
self._cached_accuracy = 0.0
|
| 103 |
+
self._cached_per_class = {}
|
| 104 |
+
self._cached_lr_accuracy = 0.0
|
| 105 |
+
self._cached_feature_importance = {}
|
| 106 |
+
self._cached = True
|
| 107 |
+
return _empty
|
| 108 |
+
|
| 109 |
+
_, X_test_df, _, y_test_df = train_test_split(
|
| 110 |
+
gt.drop("target", axis=1),
|
| 111 |
+
gt["target"],
|
| 112 |
+
test_size=test_size,
|
| 113 |
+
random_state=seed,
|
| 114 |
+
stratify=gt["target"] if gt["target"].nunique() > 1 else None,
|
| 115 |
+
)
|
| 116 |
+
y_test = y_test_df.astype(int).values
|
| 117 |
+
|
| 118 |
+
# Align columns
|
| 119 |
+
train_cols = list(wc.drop("target", axis=1).columns)
|
| 120 |
+
test_cols = list(X_test_df.columns)
|
| 121 |
+
shared = [c for c in train_cols if c in test_cols]
|
| 122 |
+
if not shared:
|
| 123 |
+
self._cache_hash = current_hash
|
| 124 |
+
self._cached_accuracy = 0.0
|
| 125 |
+
self._cached_per_class = {}
|
| 126 |
+
self._cached_lr_accuracy = 0.0
|
| 127 |
+
self._cached_feature_importance = {}
|
| 128 |
+
self._cached = True
|
| 129 |
+
return _empty
|
| 130 |
+
|
| 131 |
+
X_train_arr = wc[shared].values
|
| 132 |
+
X_test_arr = X_test_df[shared].values
|
| 133 |
+
|
| 134 |
+
# ── Primary: Random Forest ──────────────────────────────────────────
|
| 135 |
+
rf = RandomForestClassifier(
|
| 136 |
+
n_estimators=self._n_estimators,
|
| 137 |
+
random_state=42,
|
| 138 |
+
n_jobs=1,
|
| 139 |
+
)
|
| 140 |
+
rf.fit(X_train_arr, y_train)
|
| 141 |
+
y_pred_rf = rf.predict(X_test_arr)
|
| 142 |
+
|
| 143 |
+
rf_accuracy = float(rf.score(X_test_arr, y_test))
|
| 144 |
+
try:
|
| 145 |
+
per_class = classification_report(
|
| 146 |
+
y_test, y_pred_rf, output_dict=True, zero_division=0
|
| 147 |
+
)
|
| 148 |
+
except Exception:
|
| 149 |
+
per_class = {}
|
| 150 |
+
|
| 151 |
+
# Feature importance (RF gives this for free)
|
| 152 |
+
feature_importance: Dict[str, float] = {}
|
| 153 |
+
if hasattr(rf, "feature_importances_"):
|
| 154 |
+
importances = rf.feature_importances_
|
| 155 |
+
for col, imp in zip(shared, importances):
|
| 156 |
+
feature_importance[col] = round(float(imp), 4)
|
| 157 |
+
|
| 158 |
+
# ── Secondary: Logistic Regression (diagnostic) ─────────────────────
|
| 159 |
+
lr_accuracy = 0.0
|
| 160 |
+
if not self._fast_mode: # skip LR in fast_mode to keep GRPO rollouts quick
|
| 161 |
+
try:
|
| 162 |
+
lr = LogisticRegression(max_iter=500, random_state=42, n_jobs=1)
|
| 163 |
+
lr.fit(X_train_arr, y_train)
|
| 164 |
+
lr_accuracy = float(lr.score(X_test_arr, y_test))
|
| 165 |
+
except Exception:
|
| 166 |
+
lr_accuracy = 0.0
|
| 167 |
+
else:
|
| 168 |
+
# In fast_mode, reuse RF accuracy as placeholder (not shown to agent)
|
| 169 |
+
lr_accuracy = rf_accuracy
|
| 170 |
+
|
| 171 |
+
# Update cache
|
| 172 |
+
self._cache_hash = current_hash
|
| 173 |
+
self._cached_accuracy = rf_accuracy
|
| 174 |
+
self._cached_per_class = per_class
|
| 175 |
+
self._cached_lr_accuracy = lr_accuracy
|
| 176 |
+
self._cached_feature_importance = feature_importance
|
| 177 |
+
self._cached = True
|
| 178 |
+
|
| 179 |
+
return rf_accuracy, per_class, False, lr_accuracy
|
| 180 |
+
|
| 181 |
+
def agreement_signal(self, rf_acc: float, lr_acc: float,
|
| 182 |
+
prev_rf: float, prev_lr: float) -> str:
|
| 183 |
+
"""
|
| 184 |
+
Compare RF vs LR improvement direction.
|
| 185 |
+
Returns a signal string for the agent to reason about.
|
| 186 |
+
"""
|
| 187 |
+
rf_improved = rf_acc > prev_rf + 0.005
|
| 188 |
+
lr_improved = lr_acc > prev_lr + 0.005
|
| 189 |
+
rf_declined = rf_acc < prev_rf - 0.005
|
| 190 |
+
lr_declined = lr_acc < prev_lr - 0.005
|
| 191 |
+
|
| 192 |
+
if rf_improved and lr_improved:
|
| 193 |
+
return "BOTH_AGREE_IMPROVE — fix is robust and generalises"
|
| 194 |
+
elif rf_improved and lr_declined:
|
| 195 |
+
return "DISAGREE — RF improved but LR declined (possible RF-specific overfitting)"
|
| 196 |
+
elif rf_declined and lr_declined:
|
| 197 |
+
return "BOTH_DECLINED — last change hurt both classifiers, consider undo"
|
| 198 |
+
elif not rf_improved and not rf_declined:
|
| 199 |
+
return "NO_CHANGE — last operation had no measurable effect"
|
| 200 |
+
else:
|
| 201 |
+
return "MIXED — marginal changes, continue and validate again"
|
| 202 |
+
|
| 203 |
+
def feature_importance_text(self, top_n: int = 5) -> str:
|
| 204 |
+
"""Return formatted feature importance string for agent observation."""
|
| 205 |
+
if not self._cached_feature_importance:
|
| 206 |
+
return ""
|
| 207 |
+
sorted_feats = sorted(
|
| 208 |
+
self._cached_feature_importance.items(),
|
| 209 |
+
key=lambda x: -x[1]
|
| 210 |
+
)[:top_n]
|
| 211 |
+
parts = [f"{col} ({imp:.3f})" for col, imp in sorted_feats]
|
| 212 |
+
return "Feature importance: " + " > ".join(parts)
|
| 213 |
+
|
| 214 |
+
def invalidate_cache(self):
|
| 215 |
+
self._cached = False
|
| 216 |
+
self._cache_hash = None
|
| 217 |
+
|
| 218 |
+
@property
|
| 219 |
+
def last_accuracy(self) -> float:
|
| 220 |
+
return self._cached_accuracy if self._cached else 0.0
|
| 221 |
+
|
| 222 |
+
@property
|
| 223 |
+
def last_lr_accuracy(self) -> float:
|
| 224 |
+
return self._cached_lr_accuracy if self._cached else 0.0
|
| 225 |
+
|
| 226 |
+
@property
|
| 227 |
+
def last_feature_importance(self) -> Dict[str, float]:
|
| 228 |
+
return self._cached_feature_importance if self._cached else {}
|
server/requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
openenv-core>=0.2.0
|
| 2 |
+
fastapi>=0.115.0
|
| 3 |
+
uvicorn>=0.24.0
|
| 4 |
+
scikit-learn>=1.3.0
|
| 5 |
+
pandas>=2.0.0
|
| 6 |
+
numpy>=1.24.0
|
server/specialist_agents.py
ADDED
|
@@ -0,0 +1,654 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Specialist Agents for Data-Centric RL Environment.
|
| 3 |
+
|
| 4 |
+
Agents:
|
| 5 |
+
CleanerAgent — detects missing values, duplicates, type errors
|
| 6 |
+
AugmenterAgent — suggests synthetic minority class samples
|
| 7 |
+
BalancerAgent — recommends resampling strategies
|
| 8 |
+
ValidatorAgent — checks column metadata rule violations (costs 2 budget)
|
| 9 |
+
AnalystAgent — holistic diagnosis + prioritised action plan (costs 1 budget)
|
| 10 |
+
|
| 11 |
+
Also exports:
|
| 12 |
+
compute_drift() — per-column distribution drift score (no scipy needed)
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import hashlib
|
| 16 |
+
import random
|
| 17 |
+
import uuid
|
| 18 |
+
from dataclasses import dataclass, field
|
| 19 |
+
from typing import Any, Dict, List, Optional
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
import pandas as pd
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ── Recommendation / Violation dataclasses ──────────────────────────────────
|
| 26 |
+
|
| 27 |
+
@dataclass
|
| 28 |
+
class Recommendation:
|
| 29 |
+
id: int
|
| 30 |
+
description: str
|
| 31 |
+
action_type: str
|
| 32 |
+
estimated_impact: float
|
| 33 |
+
confidence: float
|
| 34 |
+
session_id: str
|
| 35 |
+
_payload: Dict[str, Any] = field(default_factory=dict, repr=False)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass
|
| 39 |
+
class Violation:
|
| 40 |
+
column: str
|
| 41 |
+
rule: str
|
| 42 |
+
count: int
|
| 43 |
+
description: str
|
| 44 |
+
severity: str = "WARNING" # NEW: CRITICAL / WARNING / INFO
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ── Session registry ─────────────────────────────────────────────────────────
|
| 48 |
+
|
| 49 |
+
class SessionRegistry:
|
| 50 |
+
"""Tracks the active recommendation session to detect stale IDs."""
|
| 51 |
+
|
| 52 |
+
def __init__(self):
|
| 53 |
+
self.current_session_id: str = ""
|
| 54 |
+
self.recommendations: Dict[int, Recommendation] = {}
|
| 55 |
+
|
| 56 |
+
def new_session(self) -> str:
|
| 57 |
+
self.current_session_id = str(uuid.uuid4())
|
| 58 |
+
self.recommendations = {}
|
| 59 |
+
return self.current_session_id
|
| 60 |
+
|
| 61 |
+
def register(self, recs: List[Recommendation]) -> None:
|
| 62 |
+
for r in recs:
|
| 63 |
+
self.recommendations[r.id] = r
|
| 64 |
+
|
| 65 |
+
def get(self, rec_id: int, session_id: str) -> Optional[Recommendation]:
|
| 66 |
+
if session_id != self.current_session_id:
|
| 67 |
+
return None
|
| 68 |
+
return self.recommendations.get(rec_id)
|
| 69 |
+
|
| 70 |
+
def is_valid_session(self, session_id: str) -> bool:
|
| 71 |
+
return session_id == self.current_session_id
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# ── Shared helpers ───────────────────────────────────────────────────────────
|
| 75 |
+
|
| 76 |
+
def _seeded_rng(df: pd.DataFrame, salt: str = "") -> random.Random:
|
| 77 |
+
h = hashlib.md5((df.to_json() + salt).encode()).hexdigest()
|
| 78 |
+
return random.Random(int(h[:8], 16))
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _col_stats(series: pd.Series) -> Dict[str, float]:
|
| 82 |
+
"""Return basic stats dict for a numeric series."""
|
| 83 |
+
s = pd.to_numeric(series, errors="coerce").dropna()
|
| 84 |
+
if len(s) == 0:
|
| 85 |
+
return {"mean": 0.0, "median": 0.0, "std": 0.0, "skew": 0.0, "n": 0}
|
| 86 |
+
return {
|
| 87 |
+
"mean": float(s.mean()),
|
| 88 |
+
"median": float(s.median()),
|
| 89 |
+
"std": float(s.std()) if len(s) > 1 else 0.0,
|
| 90 |
+
"skew": float(s.skew()) if len(s) > 2 else 0.0,
|
| 91 |
+
"n": len(s),
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _impute_strategy(stats: Dict[str, float]) -> tuple:
|
| 96 |
+
"""Choose mean vs median based on skewness. Returns (strategy, value, reason)."""
|
| 97 |
+
skew = abs(stats["skew"])
|
| 98 |
+
if skew > 1.0:
|
| 99 |
+
return "median", stats["median"], f"right-skewed (skew={stats['skew']:.2f}), median more robust"
|
| 100 |
+
elif skew > 0.5:
|
| 101 |
+
return "median", stats["median"], f"moderately skewed (skew={stats['skew']:.2f}), median preferred"
|
| 102 |
+
else:
|
| 103 |
+
return "mean", stats["mean"], f"near-symmetric (skew={stats['skew']:.2f}), mean appropriate"
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# ── Drift Detection ──────────────────────────────────────────────────────────
|
| 107 |
+
|
| 108 |
+
def compute_drift(working_copy: pd.DataFrame, ground_truth: pd.DataFrame) -> Dict[str, float]:
|
| 109 |
+
"""
|
| 110 |
+
Per-column distribution drift score comparing working_copy to ground_truth.
|
| 111 |
+
Uses mean-shift + std-ratio (no scipy dependency).
|
| 112 |
+
Returns dict: column -> drift_score (0.0 = no drift, >1.0 = HIGH drift).
|
| 113 |
+
"""
|
| 114 |
+
drift = {}
|
| 115 |
+
for col in working_copy.columns:
|
| 116 |
+
if col == "target":
|
| 117 |
+
continue
|
| 118 |
+
try:
|
| 119 |
+
wc_vals = pd.to_numeric(working_copy[col], errors="coerce").dropna()
|
| 120 |
+
gt_vals = pd.to_numeric(ground_truth[col], errors="coerce").dropna()
|
| 121 |
+
if len(wc_vals) == 0 or len(gt_vals) == 0:
|
| 122 |
+
drift[col] = 0.0
|
| 123 |
+
continue
|
| 124 |
+
mean_shift = abs(wc_vals.mean() - gt_vals.mean()) / (gt_vals.std() + 1e-8)
|
| 125 |
+
std_ratio = wc_vals.std() / (gt_vals.std() + 1e-8)
|
| 126 |
+
drift[col] = round(float(mean_shift + abs(1.0 - std_ratio)), 3)
|
| 127 |
+
except Exception:
|
| 128 |
+
drift[col] = 0.0
|
| 129 |
+
return drift
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _drift_label(score: float) -> str:
|
| 133 |
+
if score < 0.2:
|
| 134 |
+
return "NONE"
|
| 135 |
+
elif score < 0.5:
|
| 136 |
+
return "LOW"
|
| 137 |
+
elif score < 1.0:
|
| 138 |
+
return "MEDIUM"
|
| 139 |
+
else:
|
| 140 |
+
return "HIGH"
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def format_drift_summary(drift: Dict[str, float]) -> str:
|
| 144 |
+
"""Return one-line drift summary for agent observation."""
|
| 145 |
+
if not drift:
|
| 146 |
+
return ""
|
| 147 |
+
parts = [f"{col} ({_drift_label(v)})" for col, v in sorted(drift.items(), key=lambda x: -x[1])]
|
| 148 |
+
return "Distribution drift: " + " | ".join(parts[:5]) # top 5 most drifted
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
# ── CleanerAgent ─────────────────────────────────────────────────────────────
|
| 152 |
+
|
| 153 |
+
class CleanerAgent:
|
| 154 |
+
"""
|
| 155 |
+
Analyses working_copy for missing values, duplicates, type mismatches.
|
| 156 |
+
Returns 2-4 recommendations with statistical reasoning.
|
| 157 |
+
|
| 158 |
+
Hidden flaw (15% of calls, deterministic): occasionally recommends
|
| 159 |
+
removing rows that are valid. Detectable because estimated_impact < 0.
|
| 160 |
+
"""
|
| 161 |
+
|
| 162 |
+
def query(self, df: pd.DataFrame, session_registry: SessionRegistry,
|
| 163 |
+
col_meta: Dict) -> List[Recommendation]:
|
| 164 |
+
sid = session_registry.new_session()
|
| 165 |
+
rng = _seeded_rng(df, "cleaner")
|
| 166 |
+
recs = []
|
| 167 |
+
rec_id = 1
|
| 168 |
+
n_rows = len(df)
|
| 169 |
+
|
| 170 |
+
# --- Missing value recommendations with statistical reasoning ---
|
| 171 |
+
for col in df.columns:
|
| 172 |
+
if col == "target":
|
| 173 |
+
continue
|
| 174 |
+
n_missing = int(df[col].isna().sum())
|
| 175 |
+
if n_missing == 0:
|
| 176 |
+
continue
|
| 177 |
+
|
| 178 |
+
pct_missing = n_missing / n_rows * 100
|
| 179 |
+
stats = _col_stats(df[col])
|
| 180 |
+
strategy, value, reason = _impute_strategy(stats)
|
| 181 |
+
|
| 182 |
+
# Confidence: lower if >30% missing (imputation less reliable)
|
| 183 |
+
confidence = round(max(0.60, 0.92 - (pct_missing / 100) * 0.5), 2)
|
| 184 |
+
|
| 185 |
+
# Risk label
|
| 186 |
+
if pct_missing < 5:
|
| 187 |
+
risk = "LOW"
|
| 188 |
+
elif pct_missing < 20:
|
| 189 |
+
risk = "MEDIUM"
|
| 190 |
+
else:
|
| 191 |
+
risk = "HIGH — imputation may introduce bias"
|
| 192 |
+
|
| 193 |
+
mean_median_delta = abs(stats["mean"] - stats["median"])
|
| 194 |
+
description = (
|
| 195 |
+
f"Fill {n_missing}/{n_rows} ({pct_missing:.1f}%) missing values in '{col}' "
|
| 196 |
+
f"using {strategy} ({value:.2f}). "
|
| 197 |
+
f"Reason: {reason}. "
|
| 198 |
+
f"Mean={stats['mean']:.2f}, Median={stats['median']:.2f} "
|
| 199 |
+
f"(delta={mean_median_delta:.2f}). Risk: {risk}."
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
recs.append(Recommendation(
|
| 203 |
+
id=rec_id,
|
| 204 |
+
description=description,
|
| 205 |
+
action_type="fill_missing",
|
| 206 |
+
estimated_impact=round(min(0.03 + pct_missing / 100 * 0.3, 0.12), 3),
|
| 207 |
+
confidence=confidence,
|
| 208 |
+
session_id=sid,
|
| 209 |
+
_payload={"action": "fill_missing", "column": col, "strategy": strategy},
|
| 210 |
+
))
|
| 211 |
+
rec_id += 1
|
| 212 |
+
|
| 213 |
+
# --- Duplicate recommendation ---
|
| 214 |
+
n_dups = int(df.duplicated().sum())
|
| 215 |
+
if n_dups > 0:
|
| 216 |
+
pct_dups = n_dups / n_rows * 100
|
| 217 |
+
recs.append(Recommendation(
|
| 218 |
+
id=rec_id,
|
| 219 |
+
description=(
|
| 220 |
+
f"Remove {n_dups} duplicate rows ({pct_dups:.1f}% of dataset). "
|
| 221 |
+
f"Duplicates bias the classifier toward overrepresented patterns. Risk: LOW."
|
| 222 |
+
),
|
| 223 |
+
action_type="remove_duplicates",
|
| 224 |
+
estimated_impact=round(min(0.02 + n_dups / n_rows * 0.15, 0.08), 3),
|
| 225 |
+
confidence=0.92,
|
| 226 |
+
session_id=sid,
|
| 227 |
+
_payload={"action": "remove_duplicates"},
|
| 228 |
+
))
|
| 229 |
+
rec_id += 1
|
| 230 |
+
|
| 231 |
+
# --- Type error recommendations ---
|
| 232 |
+
for col in df.columns:
|
| 233 |
+
if col == "target":
|
| 234 |
+
continue
|
| 235 |
+
meta = col_meta.get(col, {})
|
| 236 |
+
expected = meta.get("expected_dtype", "float64")
|
| 237 |
+
if expected in ("float64", "int64"):
|
| 238 |
+
n_errors = sum(1 for val in df[col].dropna()
|
| 239 |
+
if not _is_numeric(val))
|
| 240 |
+
if n_errors > 0:
|
| 241 |
+
pct_err = n_errors / n_rows * 100
|
| 242 |
+
recs.append(Recommendation(
|
| 243 |
+
id=rec_id,
|
| 244 |
+
description=(
|
| 245 |
+
f"Fix {n_errors} type errors ({pct_err:.1f}%) in '{col}' "
|
| 246 |
+
f"(non-numeric values coerced to NaN, then filled with mean). "
|
| 247 |
+
f"Expected dtype: {expected}. Risk: LOW."
|
| 248 |
+
),
|
| 249 |
+
action_type="fix_type_errors",
|
| 250 |
+
estimated_impact=round(min(0.04 + n_errors / n_rows * 0.2, 0.10), 3),
|
| 251 |
+
confidence=0.88,
|
| 252 |
+
session_id=sid,
|
| 253 |
+
_payload={"action": "fix_type_errors", "column": col},
|
| 254 |
+
))
|
| 255 |
+
rec_id += 1
|
| 256 |
+
|
| 257 |
+
# --- Hidden flaw: ~15% chance of recommending valid row removal ---
|
| 258 |
+
flaw_hash = int(hashlib.md5(df.to_json().encode()).hexdigest()[:4], 16)
|
| 259 |
+
if flaw_hash % 100 < 15 and len(recs) < 4:
|
| 260 |
+
col = rng.choice([c for c in df.columns if c != "target"])
|
| 261 |
+
recs.append(Recommendation(
|
| 262 |
+
id=rec_id,
|
| 263 |
+
description=(
|
| 264 |
+
f"Remove rows where '{col}' is below the 5th percentile "
|
| 265 |
+
f"(suspected outliers). Confidence LOW — verify with query_validator first."
|
| 266 |
+
),
|
| 267 |
+
action_type="remove_outlier_rows",
|
| 268 |
+
estimated_impact=round(rng.uniform(-0.05, 0.01), 3),
|
| 269 |
+
confidence=round(rng.uniform(0.55, 0.70), 2),
|
| 270 |
+
session_id=sid,
|
| 271 |
+
_payload={"action": "remove_outlier_rows", "column": col, "pct": 5},
|
| 272 |
+
))
|
| 273 |
+
|
| 274 |
+
# Keep top 4 by estimated_impact, re-number
|
| 275 |
+
recs = sorted(recs, key=lambda r: -r.estimated_impact)[:4]
|
| 276 |
+
for i, r in enumerate(recs, 1):
|
| 277 |
+
r.id = i
|
| 278 |
+
session_registry.register(recs)
|
| 279 |
+
return recs
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def _is_numeric(val) -> bool:
|
| 283 |
+
try:
|
| 284 |
+
float(val)
|
| 285 |
+
return True
|
| 286 |
+
except (ValueError, TypeError):
|
| 287 |
+
return False
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
# ── AugmenterAgent ───────────────────────────────────────────────────────────
|
| 291 |
+
|
| 292 |
+
class AugmenterAgent:
|
| 293 |
+
"""
|
| 294 |
+
Detects underrepresented classes and suggests synthetic samples.
|
| 295 |
+
Returns 1-3 recommendations with class distribution reasoning.
|
| 296 |
+
|
| 297 |
+
Hidden flaw: sometimes suggests out-of-distribution samples (flagged).
|
| 298 |
+
"""
|
| 299 |
+
|
| 300 |
+
def query(self, df: pd.DataFrame, session_registry: SessionRegistry,
|
| 301 |
+
class_name: Optional[str] = None) -> List[Recommendation]:
|
| 302 |
+
sid = session_registry.new_session()
|
| 303 |
+
rng = _seeded_rng(df, "augmenter")
|
| 304 |
+
value_counts = df["target"].value_counts()
|
| 305 |
+
total = len(df)
|
| 306 |
+
recs = []
|
| 307 |
+
rec_id = 1
|
| 308 |
+
|
| 309 |
+
targets = [class_name] if class_name else [str(c) for c in value_counts.index]
|
| 310 |
+
|
| 311 |
+
# Class distribution context
|
| 312 |
+
dist_str = ", ".join(f"class {k}: {v} ({v/total*100:.1f}%)"
|
| 313 |
+
for k, v in value_counts.items())
|
| 314 |
+
|
| 315 |
+
for cls in targets[:3]:
|
| 316 |
+
try:
|
| 317 |
+
cls_int = int(cls)
|
| 318 |
+
except (ValueError, TypeError):
|
| 319 |
+
continue
|
| 320 |
+
if cls_int not in value_counts.index:
|
| 321 |
+
continue
|
| 322 |
+
|
| 323 |
+
count = value_counts[cls_int]
|
| 324 |
+
max_count = value_counts.max()
|
| 325 |
+
gap = max_count - count
|
| 326 |
+
if gap <= 0:
|
| 327 |
+
continue
|
| 328 |
+
|
| 329 |
+
n_synth = min(gap, max(5, int(gap * 0.5)))
|
| 330 |
+
ratio_before = count / max_count
|
| 331 |
+
ratio_after = (count + n_synth) / max_count
|
| 332 |
+
|
| 333 |
+
flaw_hash = int(hashlib.md5((df.to_json() + cls).encode()).hexdigest()[:4], 16)
|
| 334 |
+
is_ood = flaw_hash % 100 < 20
|
| 335 |
+
impact = round(min(0.04 + n_synth / total * 0.4, 0.10), 3)
|
| 336 |
+
if is_ood:
|
| 337 |
+
impact = round(rng.uniform(-0.02, 0.02), 3)
|
| 338 |
+
|
| 339 |
+
ood_note = " [WARNING: high OOD risk — run query_validator before applying]" if is_ood else ""
|
| 340 |
+
risk = "HIGH" if is_ood else ("MEDIUM" if ratio_before < 0.3 else "LOW")
|
| 341 |
+
|
| 342 |
+
description = (
|
| 343 |
+
f"Generate {n_synth} synthetic samples for class '{cls}' via Gaussian perturbation. "
|
| 344 |
+
f"Distribution: {dist_str}. "
|
| 345 |
+
f"Imbalance ratio before: {ratio_before:.2f} → after: {ratio_after:.2f}. "
|
| 346 |
+
f"Risk: {risk}.{ood_note}"
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
recs.append(Recommendation(
|
| 350 |
+
id=rec_id,
|
| 351 |
+
description=description,
|
| 352 |
+
action_type="augment_class",
|
| 353 |
+
estimated_impact=impact,
|
| 354 |
+
confidence=round(0.60 if is_ood else 0.82, 2),
|
| 355 |
+
session_id=sid,
|
| 356 |
+
_payload={
|
| 357 |
+
"action": "augment_class",
|
| 358 |
+
"class": cls_int,
|
| 359 |
+
"n_synth": n_synth,
|
| 360 |
+
"ood": is_ood,
|
| 361 |
+
},
|
| 362 |
+
))
|
| 363 |
+
rec_id += 1
|
| 364 |
+
|
| 365 |
+
session_registry.register(recs)
|
| 366 |
+
return recs
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
# ── BalancerAgent ─────────────────────────────────────────────────────────────
|
| 370 |
+
|
| 371 |
+
class BalancerAgent:
|
| 372 |
+
"""
|
| 373 |
+
Recommends resampling strategies for class imbalance.
|
| 374 |
+
Returns 1-2 recommendations with entropy and ratio reasoning.
|
| 375 |
+
|
| 376 |
+
Hidden flaw: occasionally over-balances (minority becomes too large).
|
| 377 |
+
"""
|
| 378 |
+
|
| 379 |
+
def query(self, df: pd.DataFrame, session_registry: SessionRegistry) -> List[Recommendation]:
|
| 380 |
+
sid = session_registry.new_session()
|
| 381 |
+
rng = _seeded_rng(df, "balancer")
|
| 382 |
+
value_counts = df["target"].value_counts()
|
| 383 |
+
recs = []
|
| 384 |
+
rec_id = 1
|
| 385 |
+
|
| 386 |
+
if len(value_counts) < 2:
|
| 387 |
+
session_registry.register([])
|
| 388 |
+
return []
|
| 389 |
+
|
| 390 |
+
min_cls = int(value_counts.idxmin())
|
| 391 |
+
max_cls = int(value_counts.idxmax())
|
| 392 |
+
min_count = int(value_counts.min())
|
| 393 |
+
max_count = int(value_counts.max())
|
| 394 |
+
imbalance_ratio = min_count / max_count
|
| 395 |
+
|
| 396 |
+
# Class distribution entropy (0=perfectly imbalanced, 1=perfectly balanced)
|
| 397 |
+
probs = value_counts / value_counts.sum()
|
| 398 |
+
entropy = float(-np.sum(probs * np.log2(probs + 1e-9)))
|
| 399 |
+
max_entropy = np.log2(len(value_counts))
|
| 400 |
+
entropy_pct = entropy / max_entropy * 100 if max_entropy > 0 else 0
|
| 401 |
+
|
| 402 |
+
flaw_hash = int(hashlib.md5(df.to_json().encode()).hexdigest()[:4], 16)
|
| 403 |
+
is_overbalance = flaw_hash % 100 < 20
|
| 404 |
+
target_count = max_count if not is_overbalance else int(max_count * 1.5)
|
| 405 |
+
ratio_after = min(1.0, min_count / target_count) if target_count > 0 else 1.0
|
| 406 |
+
|
| 407 |
+
overbalance_note = (
|
| 408 |
+
" [WARNING: target exceeds majority class size — may over-correct and hurt generalisation]"
|
| 409 |
+
if is_overbalance else ""
|
| 410 |
+
)
|
| 411 |
+
risk = "HIGH" if is_overbalance else ("MEDIUM" if imbalance_ratio < 0.3 else "LOW")
|
| 412 |
+
|
| 413 |
+
recs.append(Recommendation(
|
| 414 |
+
id=rec_id,
|
| 415 |
+
description=(
|
| 416 |
+
f"Upsample minority class {min_cls} from {min_count} to {target_count} rows "
|
| 417 |
+
f"via random oversampling. "
|
| 418 |
+
f"Imbalance ratio: {imbalance_ratio:.2f} → {ratio_after:.2f}. "
|
| 419 |
+
f"Class entropy: {entropy_pct:.1f}% of maximum. Risk: {risk}.{overbalance_note}"
|
| 420 |
+
),
|
| 421 |
+
action_type="oversample",
|
| 422 |
+
estimated_impact=round(min(0.05 + (1 - imbalance_ratio) * 0.15, 0.12), 3),
|
| 423 |
+
confidence=round(0.60 if is_overbalance else 0.80, 2),
|
| 424 |
+
session_id=sid,
|
| 425 |
+
_payload={
|
| 426 |
+
"action": "oversample",
|
| 427 |
+
"class": min_cls,
|
| 428 |
+
"target_count": target_count,
|
| 429 |
+
"overbalance": is_overbalance,
|
| 430 |
+
},
|
| 431 |
+
))
|
| 432 |
+
rec_id += 1
|
| 433 |
+
|
| 434 |
+
if imbalance_ratio < 0.5:
|
| 435 |
+
undersample_target = min_count * 2
|
| 436 |
+
recs.append(Recommendation(
|
| 437 |
+
id=rec_id,
|
| 438 |
+
description=(
|
| 439 |
+
f"Downsample majority class {max_cls} from {max_count} to {undersample_target} rows "
|
| 440 |
+
f"via random undersampling. "
|
| 441 |
+
f"Warning: loses {max_count - undersample_target} majority-class examples. "
|
| 442 |
+
f"Risk: MEDIUM — use only if dataset is large enough."
|
| 443 |
+
),
|
| 444 |
+
action_type="undersample",
|
| 445 |
+
estimated_impact=round(min(0.03 + (1 - imbalance_ratio) * 0.08, 0.08), 3),
|
| 446 |
+
confidence=0.75,
|
| 447 |
+
session_id=sid,
|
| 448 |
+
_payload={
|
| 449 |
+
"action": "undersample",
|
| 450 |
+
"class": max_cls,
|
| 451 |
+
"target_count": undersample_target,
|
| 452 |
+
},
|
| 453 |
+
))
|
| 454 |
+
|
| 455 |
+
session_registry.register(recs)
|
| 456 |
+
return recs
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
# ── ValidatorAgent ────────────────────────────────────────────────────────────
|
| 460 |
+
|
| 461 |
+
class ValidatorAgent:
|
| 462 |
+
"""
|
| 463 |
+
Checks working_copy against column metadata for rule violations.
|
| 464 |
+
Returns list of Violation objects (diagnostic only — not recommendations).
|
| 465 |
+
Costs 2 budget per call.
|
| 466 |
+
~10% false positive rate (flagged with [FALSE POSITIVE WARNING]).
|
| 467 |
+
"""
|
| 468 |
+
|
| 469 |
+
def query(self, df: pd.DataFrame, col_meta: Dict) -> List[Violation]:
|
| 470 |
+
rng = _seeded_rng(df, "validator")
|
| 471 |
+
violations = []
|
| 472 |
+
|
| 473 |
+
for col, meta in col_meta.items():
|
| 474 |
+
if col == "target" or col not in df.columns:
|
| 475 |
+
continue
|
| 476 |
+
|
| 477 |
+
expected_dtype = meta.get("expected_dtype", "float64")
|
| 478 |
+
valid_range = meta.get("valid_range")
|
| 479 |
+
|
| 480 |
+
# Type violations
|
| 481 |
+
if expected_dtype in ("float64", "int64"):
|
| 482 |
+
n_errors = sum(1 for val in df[col].dropna() if not _is_numeric(val))
|
| 483 |
+
if n_errors > 0:
|
| 484 |
+
pct = n_errors / len(df) * 100
|
| 485 |
+
violations.append(Violation(
|
| 486 |
+
column=col,
|
| 487 |
+
rule=f"dtype={expected_dtype}",
|
| 488 |
+
count=n_errors,
|
| 489 |
+
description=f"{n_errors} non-numeric values in '{col}' ({pct:.1f}%). Recommend fix_type_errors.",
|
| 490 |
+
severity="CRITICAL" if pct > 10 else "WARNING",
|
| 491 |
+
))
|
| 492 |
+
|
| 493 |
+
# Range violations
|
| 494 |
+
if valid_range:
|
| 495 |
+
lo, hi = valid_range
|
| 496 |
+
try:
|
| 497 |
+
numeric_vals = pd.to_numeric(df[col], errors="coerce").dropna()
|
| 498 |
+
n_out = int(((numeric_vals < lo) | (numeric_vals > hi)).sum())
|
| 499 |
+
if n_out > 0:
|
| 500 |
+
max_val = float(numeric_vals.max())
|
| 501 |
+
min_val = float(numeric_vals.min())
|
| 502 |
+
std = float(numeric_vals.std()) or 1.0
|
| 503 |
+
z_max = abs(max_val - numeric_vals.mean()) / std
|
| 504 |
+
violations.append(Violation(
|
| 505 |
+
column=col,
|
| 506 |
+
rule=f"range=[{lo},{hi}]",
|
| 507 |
+
count=n_out,
|
| 508 |
+
description=(
|
| 509 |
+
f"{n_out} values in '{col}' outside [{lo}, {hi}]. "
|
| 510 |
+
f"Observed range: [{min_val:.1f}, {max_val:.1f}] "
|
| 511 |
+
f"(max Z-score: {z_max:.1f}). "
|
| 512 |
+
f"Severity: {'CRITICAL — likely data corruption' if z_max > 5 else 'WARNING — possible outliers'}."
|
| 513 |
+
),
|
| 514 |
+
severity="CRITICAL" if z_max > 5 else "WARNING",
|
| 515 |
+
))
|
| 516 |
+
except Exception:
|
| 517 |
+
pass
|
| 518 |
+
|
| 519 |
+
# ~10% false positive
|
| 520 |
+
fp_hash = int(hashlib.md5(df.to_json().encode()).hexdigest()[:4], 16)
|
| 521 |
+
if fp_hash % 100 < 10:
|
| 522 |
+
feature_cols = [c for c in df.columns if c != "target"]
|
| 523 |
+
if feature_cols:
|
| 524 |
+
fp_col = rng.choice(feature_cols)
|
| 525 |
+
violations.append(Violation(
|
| 526 |
+
column=fp_col,
|
| 527 |
+
rule="distribution_check",
|
| 528 |
+
count=rng.randint(1, 5),
|
| 529 |
+
description=(
|
| 530 |
+
f"[FALSE POSITIVE WARNING] Unusual value distribution in '{fp_col}' "
|
| 531 |
+
f"— may not be a real issue. Verify before acting."
|
| 532 |
+
),
|
| 533 |
+
severity="INFO",
|
| 534 |
+
))
|
| 535 |
+
|
| 536 |
+
return violations
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
# ── AnalystAgent ──────────────────────────────────────────────────────────────
|
| 540 |
+
|
| 541 |
+
class AnalystAgent:
|
| 542 |
+
"""
|
| 543 |
+
Meta-specialist that performs holistic dataset diagnosis.
|
| 544 |
+
Returns a prioritised action plan rather than individual recommendations.
|
| 545 |
+
Costs 1 budget.
|
| 546 |
+
|
| 547 |
+
Analyses:
|
| 548 |
+
- Missing value severity
|
| 549 |
+
- Class imbalance severity
|
| 550 |
+
- Type error severity
|
| 551 |
+
- Remaining accuracy gap
|
| 552 |
+
Then ranks problems and recommends an ordered sequence of specialist calls.
|
| 553 |
+
"""
|
| 554 |
+
|
| 555 |
+
def query(
|
| 556 |
+
self,
|
| 557 |
+
df: pd.DataFrame,
|
| 558 |
+
col_meta: Dict,
|
| 559 |
+
current_accuracy: float,
|
| 560 |
+
target_accuracy: float,
|
| 561 |
+
budget_remaining: int,
|
| 562 |
+
) -> str:
|
| 563 |
+
"""Return a formatted diagnostic + action plan string."""
|
| 564 |
+
|
| 565 |
+
n_rows = max(len(df), 1)
|
| 566 |
+
n_cells = n_rows * max(len(df.columns) - 1, 1)
|
| 567 |
+
|
| 568 |
+
# ── Score each problem dimension (0.0 – 1.0) ──────────────────────
|
| 569 |
+
|
| 570 |
+
# 1. Missing value severity
|
| 571 |
+
total_missing = int(df.isnull().sum().sum())
|
| 572 |
+
missing_severity = min(1.0, total_missing / n_cells * 5)
|
| 573 |
+
|
| 574 |
+
# 2. Class imbalance severity
|
| 575 |
+
vc = df["target"].value_counts()
|
| 576 |
+
if len(vc) >= 2:
|
| 577 |
+
imbalance_severity = 1.0 - (vc.min() / vc.max())
|
| 578 |
+
else:
|
| 579 |
+
imbalance_severity = 0.0
|
| 580 |
+
|
| 581 |
+
# 3. Type error severity
|
| 582 |
+
n_type_errors = 0
|
| 583 |
+
for col in df.columns:
|
| 584 |
+
if col == "target":
|
| 585 |
+
continue
|
| 586 |
+
meta = col_meta.get(col, {})
|
| 587 |
+
if meta.get("expected_dtype", "float64") in ("float64", "int64"):
|
| 588 |
+
n_type_errors += sum(1 for val in df[col].dropna() if not _is_numeric(val))
|
| 589 |
+
type_severity = min(1.0, n_type_errors / n_cells * 10)
|
| 590 |
+
|
| 591 |
+
# 4. Accuracy gap
|
| 592 |
+
accuracy_gap = max(0.0, target_accuracy - current_accuracy)
|
| 593 |
+
|
| 594 |
+
# ── Rank problems ─────────────────────────────────────────────────
|
| 595 |
+
problems = [
|
| 596 |
+
("class imbalance", imbalance_severity, "query_balancer"),
|
| 597 |
+
("missing values", missing_severity, "query_cleaner"),
|
| 598 |
+
("type errors", type_severity, "query_cleaner"),
|
| 599 |
+
]
|
| 600 |
+
problems.sort(key=lambda x: -x[1])
|
| 601 |
+
|
| 602 |
+
# ── Build diagnosis section ───────────────────────────────────────
|
| 603 |
+
diagnosis_lines = ["DIAGNOSIS:"]
|
| 604 |
+
for name, severity, specialist in problems:
|
| 605 |
+
if severity < 0.05:
|
| 606 |
+
level = "NONE"
|
| 607 |
+
elif severity < 0.3:
|
| 608 |
+
level = "LOW"
|
| 609 |
+
elif severity < 0.6:
|
| 610 |
+
level = "MEDIUM"
|
| 611 |
+
else:
|
| 612 |
+
level = "HIGH"
|
| 613 |
+
diagnosis_lines.append(
|
| 614 |
+
f" - {name.title()}: severity={severity:.2f} [{level}] -> use {specialist}"
|
| 615 |
+
)
|
| 616 |
+
diagnosis_lines.append(
|
| 617 |
+
f" - Accuracy gap: {accuracy_gap:.4f} "
|
| 618 |
+
f"({'within reach' if accuracy_gap < 0.05 else 'significant gap'})"
|
| 619 |
+
)
|
| 620 |
+
|
| 621 |
+
# ── Build action plan ─���───────────────────────────────────────────
|
| 622 |
+
plan_lines = [f"\nRECOMMENDED PLAN (budget remaining: {budget_remaining}):"]
|
| 623 |
+
step = 1
|
| 624 |
+
|
| 625 |
+
# Recommend top 2 non-trivial problems
|
| 626 |
+
for name, severity, specialist in problems:
|
| 627 |
+
if severity >= 0.1:
|
| 628 |
+
plan_lines.append(f" {step}. {specialist} → apply best recommendation")
|
| 629 |
+
step += 1
|
| 630 |
+
if step > 3:
|
| 631 |
+
break
|
| 632 |
+
|
| 633 |
+
# Always validate after fixes
|
| 634 |
+
plan_lines.append(f" {step}. validate (check accuracy improvement)")
|
| 635 |
+
step += 1
|
| 636 |
+
|
| 637 |
+
# Budget guidance
|
| 638 |
+
if budget_remaining <= 8:
|
| 639 |
+
plan_lines.append(
|
| 640 |
+
f" {step}. submit NOW — budget is critically low ({budget_remaining} steps left)"
|
| 641 |
+
)
|
| 642 |
+
plan_lines.append(" NOTE: Skip query_validator (costs 2 budget).")
|
| 643 |
+
elif accuracy_gap < 0.02:
|
| 644 |
+
plan_lines.append(f" {step}. submit — you are very close to target")
|
| 645 |
+
else:
|
| 646 |
+
plan_lines.append(f" {step}. Repeat if accuracy gap remains > 0.02, then submit")
|
| 647 |
+
|
| 648 |
+
# Feature note
|
| 649 |
+
if imbalance_severity > missing_severity and imbalance_severity > 0.2:
|
| 650 |
+
plan_lines.append("\nPRIORITY NOTE: Class imbalance is the dominant issue — fix this first.")
|
| 651 |
+
elif missing_severity > 0.2:
|
| 652 |
+
plan_lines.append("\nPRIORITY NOTE: High missing-value rate — clean data before augmenting.")
|
| 653 |
+
|
| 654 |
+
return "\n".join(diagnosis_lines + plan_lines)
|
sft_generator.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Ultra-Fast SFT Data Generator — No sklearn, No Environment Execution.
|
| 3 |
+
|
| 4 |
+
Instead of running the environment live, we generate realistic prompt/response
|
| 5 |
+
pairs directly from templates using known dataset states.
|
| 6 |
+
|
| 7 |
+
This is correct because:
|
| 8 |
+
- We know exactly what inspect_dataset returns (from dataset_generator)
|
| 9 |
+
- We know what query_cleaner returns (from specialist_agents)
|
| 10 |
+
- We know the reward trajectory
|
| 11 |
+
- The actual RL training will run the real environment — SFT just warms up
|
| 12 |
+
the LLM's action distribution (command grammar + strategy)
|
| 13 |
+
|
| 14 |
+
Output: ~1000+ diverse examples in under 10 seconds.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import json
|
| 18 |
+
import os
|
| 19 |
+
import random
|
| 20 |
+
import sys
|
| 21 |
+
|
| 22 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 23 |
+
|
| 24 |
+
from server.dataset_generator import TASK_CONFIGS
|
| 25 |
+
|
| 26 |
+
rng = random.Random(42)
|
| 27 |
+
|
| 28 |
+
TASKS = list(TASK_CONFIGS.keys())
|
| 29 |
+
|
| 30 |
+
# ── Prompt templates ──────────────────────────────────────────────────────────
|
| 31 |
+
|
| 32 |
+
def make_prompt(
|
| 33 |
+
task: str,
|
| 34 |
+
step: int,
|
| 35 |
+
max_steps: int,
|
| 36 |
+
current_acc: float,
|
| 37 |
+
target_acc: float,
|
| 38 |
+
baseline_acc: float,
|
| 39 |
+
dataset_shape: str,
|
| 40 |
+
rows_pct: float,
|
| 41 |
+
quality: float,
|
| 42 |
+
budget: int,
|
| 43 |
+
session: str,
|
| 44 |
+
validate_left: int,
|
| 45 |
+
last_obs: str,
|
| 46 |
+
) -> str:
|
| 47 |
+
gap = max(0.0, target_acc - current_acc)
|
| 48 |
+
return (
|
| 49 |
+
f"You are a Data-Centric AI agent improving an ML dataset.\n\n"
|
| 50 |
+
f"Task: {task}\n"
|
| 51 |
+
f"Step: {step}/{max_steps}\n"
|
| 52 |
+
f"Current accuracy: {current_acc:.4f} "
|
| 53 |
+
f"Target: {target_acc:.4f} Gap: {gap:.4f}\n"
|
| 54 |
+
f"Baseline accuracy: {baseline_acc:.4f}\n"
|
| 55 |
+
f"Dataset: {dataset_shape} | "
|
| 56 |
+
f"Rows preserved: {rows_pct*100:.1f}%\n"
|
| 57 |
+
f"Quality score: {quality:.4f} | "
|
| 58 |
+
f"Budget remaining: {budget}\n"
|
| 59 |
+
f"Active session: {session} | "
|
| 60 |
+
f"Validate calls left: {validate_left}\n\n"
|
| 61 |
+
f"Last observation:\n{last_obs}\n\n"
|
| 62 |
+
f"What is your next command?"
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
# ── Observation text snippets ────────────────────────────────────────────────
|
| 67 |
+
|
| 68 |
+
INSPECT_OBS_TEMPLATES = [
|
| 69 |
+
"=== Dataset Inspection ===\nShape: {rows} rows × {cols} features\nOriginal rows: {rows} | Preserved: 100.0%\nDuplicates: {dups}\nMissing values:\n {col}: {missing}\nClass distribution: {dist}\nDtypes: {{'age': 'float64', 'score': 'float64', 'target': 'int64'}}",
|
| 70 |
+
"=== Dataset Inspection ===\nShape: {rows} rows × {cols} features\nDuplicates: {dups}\nMissing values:\n {col}: {missing}\nClass distribution: {dist}",
|
| 71 |
+
]
|
| 72 |
+
|
| 73 |
+
INSPECT_MODEL_TEMPLATES = [
|
| 74 |
+
"=== Model Inspection ===\nAccuracy: {acc:.4f}\n Class 0: precision={p0:.3f} recall={r0:.3f} f1={f0:.3f}\n Class 1: precision={p1:.3f} recall={r1:.3f} f1={f1:.3f}\nTarget: {target:.4f} | Not yet",
|
| 75 |
+
"=== Model Inspection (cached) ===\nAccuracy: {acc:.4f}\nTarget: {target:.4f} | Not yet",
|
| 76 |
+
]
|
| 77 |
+
|
| 78 |
+
CLEANER_OBS_TEMPLATES = [
|
| 79 |
+
"=== Cleaner Recommendations ===\n[1] Fill {n} missing values in '{col}' using mean ({mean:.2f})\n type=fill_missing impact=+0.075 confidence=0.90\n[2] Remove {dups} duplicate rows\n type=remove_duplicates impact=+0.020 confidence=0.95",
|
| 80 |
+
"=== Cleaner Recommendations ===\n[1] Fill {n} missing values in '{col}' using mean ({mean:.2f})\n type=fill_missing impact=+0.075 confidence=0.90\n[2] Fix {typos} type errors in 'income'\n type=fix_type_errors impact=+0.040 confidence=0.75",
|
| 81 |
+
"=== Cleaner Recommendations ===\n[1] Fill {n} missing values in '{col}' using mean ({mean:.2f})\n type=fill_missing impact=+0.075 confidence=0.90",
|
| 82 |
+
]
|
| 83 |
+
|
| 84 |
+
BALANCER_OBS_TEMPLATES = [
|
| 85 |
+
"=== Balancer Recommendations ===\n[1] Upsample minority class 1 from {min_c} to {maj_c} rows via random oversampling (imbalance ratio: {ratio:.2f})\n type=oversample impact=+0.053 confidence=0.80",
|
| 86 |
+
"=== Balancer Recommendations ===\n[1] Downsample majority class 0 from {maj_c} to {min_c} rows\n type=undersample impact=+0.030 confidence=0.70",
|
| 87 |
+
]
|
| 88 |
+
|
| 89 |
+
APPLY_OBS_TEMPLATES = [
|
| 90 |
+
"Applied: fill_missing [Fill {n} missing values in '{col}' using mean ({mean:.2f})]\n\nDataset health check:\n Missing values: {remaining} remaining (was {was})\n Duplicates: ✓ (was 0)\n Row count: {rows}/{orig} (100.0% preserved)\n\nEstimated quality score: {quality:.4f}\nBudget remaining: {budget}",
|
| 91 |
+
"Applied: remove_duplicates [Remove {dups} duplicate rows]\n\nDataset health check:\n Missing values: {remaining} remaining (was {was})\n Duplicates: ✓ (was {dups})\n Row count: {rows}/{orig} ({pct:.1f}% preserved)\n\nEstimated quality score: {quality:.4f}\nBudget remaining: {budget}",
|
| 92 |
+
"Applied: oversample [Upsample minority class 1 via random oversampling]\n\nDataset health check:\n Missing values: 0 remaining (was 0)\n Duplicates: 2 remaining (was 0)\n Row count: {rows}/{orig} (102.0% preserved)\n\nEstimated quality score: {quality:.4f}\nBudget remaining: {budget}",
|
| 93 |
+
]
|
| 94 |
+
|
| 95 |
+
VALIDATE_OBS_TEMPLATES = [
|
| 96 |
+
"=== Validate ===\nRF Accuracy: {acc:.4f} (primary)\nLR Accuracy: {lr_acc:.4f} (secondary)\nAgreement: BOTH_AGREE_IMPROVE -- fix is robust and generalises\n Class 0: p={p:.3f} r={r:.3f} f1={f:.3f}\n Class 1: p={p:.3f} r={r:.3f} f1={f:.3f}\nTarget: {target:.4f} | {status}",
|
| 97 |
+
"=== Validate ===\nRF Accuracy: {acc:.4f} (primary)\nLR Accuracy: {lr_acc:.4f} (secondary)\nAgreement: BOTH_AGREE_IMPROVE -- fix is robust and generalises\nTarget: {target:.4f} | {status}",
|
| 98 |
+
"=== Validate (cached) ===\nRF Accuracy: {acc:.4f} (primary)\nLR Accuracy: {lr_acc:.4f} (secondary)\nTarget: {target:.4f} | {status}",
|
| 99 |
+
]
|
| 100 |
+
|
| 101 |
+
ERROR_OBS_TEMPLATES = [
|
| 102 |
+
"Error: Recommendation 1 has already been applied this session. Duplicate apply not allowed.",
|
| 103 |
+
"Validate on cooldown. Take 1 more action(s) before validating again.",
|
| 104 |
+
"Error: stale recommendation ID 99. Please re-query for fresh recommendations.",
|
| 105 |
+
]
|
| 106 |
+
|
| 107 |
+
RESET_OBS = (
|
| 108 |
+
"Episode started: {task}\n"
|
| 109 |
+
"Baseline accuracy: {baseline:.4f} | Target: {target:.4f}\n"
|
| 110 |
+
"Dataset: {rows} rows x {cols} features\n"
|
| 111 |
+
"Budget: {budget} steps\n\n"
|
| 112 |
+
"Available commands:\n"
|
| 113 |
+
" inspect_dataset - shape, dtypes, missing, class distribution\n"
|
| 114 |
+
" inspect_model - accuracy (RF + LR), F1, feature importance\n"
|
| 115 |
+
" query_analyst - holistic diagnosis + prioritised action plan (costs 1 budget)\n"
|
| 116 |
+
" query_cleaner - get cleaning recommendations\n"
|
| 117 |
+
" query_augmenter [class] - get augmentation suggestions\n"
|
| 118 |
+
" query_balancer - get resampling recommendations\n"
|
| 119 |
+
" query_validator - check rule violations (costs 2 budget)\n"
|
| 120 |
+
" apply [id] - apply recommendation by ID\n"
|
| 121 |
+
" reject [id] - reject a recommendation\n"
|
| 122 |
+
" validate - retrain and score (cooldown applies)\n"
|
| 123 |
+
" submit - finalize episode"
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
ANALYST_OBS_TEMPLATES = [
|
| 127 |
+
"=== Analyst Report (costs 1 budget) ===\nDIAGNOSIS:\n - Class Imbalance: severity={imb:.2f} [HIGH] -> use query_balancer\n - Missing Values: severity={miss:.2f} [MEDIUM] -> use query_cleaner\n - Type Errors: severity=0.00 [NONE]\n - Accuracy gap: {gap:.4f} (significant gap)\n\nRECOMMENDED PLAN (budget remaining: {budget}):\n 1. query_balancer -> apply best recommendation\n 2. query_cleaner -> apply best recommendation\n 3. validate (check accuracy improvement)\n 4. submit if accuracy >= target\n\nPRIORITY NOTE: Class imbalance is the dominant issue -- fix this first.",
|
| 128 |
+
"=== Analyst Report (costs 1 budget) ===\nDIAGNOSIS:\n - Missing Values: severity={miss:.2f} [HIGH] -> use query_cleaner\n - Class Imbalance: severity={imb:.2f} [LOW] -> use query_balancer\n - Type Errors: severity=0.00 [NONE]\n - Accuracy gap: {gap:.4f} (significant gap)\n\nRECOMMENDED PLAN (budget remaining: {budget}):\n 1. query_cleaner -> apply best recommendation\n 2. query_balancer -> apply best recommendation\n 3. validate\n 4. submit",
|
| 129 |
+
]
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# ── Episode builders ─────────────────────────────────────────────────────────
|
| 133 |
+
|
| 134 |
+
def sample_dataset_params(task: str, seed: int):
|
| 135 |
+
"""Sample realistic dataset params for a given task."""
|
| 136 |
+
cfg = TASK_CONFIGS[task]
|
| 137 |
+
rng2 = random.Random(seed)
|
| 138 |
+
rows_map = {"task_0_tutorial": 100, "task_1_easy": 200,
|
| 139 |
+
"task_2_medium": 500, "task_3_hard": 900}
|
| 140 |
+
cols_map = {"task_0_tutorial": 4, "task_1_easy": 5,
|
| 141 |
+
"task_2_medium": 7, "task_3_hard": 10}
|
| 142 |
+
rows = rows_map[task]
|
| 143 |
+
cols = cols_map[task]
|
| 144 |
+
missing_cols = ["age", "income", "score"][:rng2.randint(1, 3)]
|
| 145 |
+
missing_pct = rng2.uniform(0.10, 0.30)
|
| 146 |
+
n_missing = int(rows * missing_pct)
|
| 147 |
+
mean_val = rng2.uniform(30.0, 60.0)
|
| 148 |
+
dups = rng2.randint(0, int(rows * 0.05))
|
| 149 |
+
maj_class = int(rows * rng2.uniform(0.52, 0.65))
|
| 150 |
+
min_class = rows - maj_class
|
| 151 |
+
return {
|
| 152 |
+
"task": task, "rows": rows, "cols": cols,
|
| 153 |
+
"missing_col": missing_cols[0], "n_missing": n_missing,
|
| 154 |
+
"mean_val": round(mean_val, 2), "dups": dups,
|
| 155 |
+
"maj_class": maj_class, "min_class": min_class,
|
| 156 |
+
"baseline": cfg["baseline_accuracy"],
|
| 157 |
+
"target": cfg["target_accuracy"],
|
| 158 |
+
"budget": cfg["budget"],
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def build_episode(task: str, seed: int, strategy: list) -> list:
|
| 163 |
+
"""
|
| 164 |
+
Build a synthetic SFT episode using template obs + fixed action sequence.
|
| 165 |
+
Returns list of {prompt, response} dicts.
|
| 166 |
+
"""
|
| 167 |
+
p = sample_dataset_params(task, seed)
|
| 168 |
+
cfg = TASK_CONFIGS[task]
|
| 169 |
+
examples = []
|
| 170 |
+
|
| 171 |
+
acc = p["baseline"]
|
| 172 |
+
quality = round(rng.uniform(0.45, 0.65), 4)
|
| 173 |
+
rows = p["rows"]
|
| 174 |
+
missing_remaining = p["n_missing"]
|
| 175 |
+
budget = p["budget"]
|
| 176 |
+
session = "none"
|
| 177 |
+
validate_left = 3
|
| 178 |
+
prev_obs = RESET_OBS.format(
|
| 179 |
+
task=task, baseline=p["baseline"], target=p["target"],
|
| 180 |
+
rows=rows, cols=p["cols"], budget=budget
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
for step, action in enumerate(strategy):
|
| 184 |
+
prompt = make_prompt(
|
| 185 |
+
task=task, step=step, max_steps=p["budget"],
|
| 186 |
+
current_acc=acc, target_acc=p["target"], baseline_acc=p["baseline"],
|
| 187 |
+
dataset_shape=f"{rows} rows × {p['cols']} columns",
|
| 188 |
+
rows_pct=rows / p["rows"], quality=quality, budget=budget,
|
| 189 |
+
session=session, validate_left=validate_left, last_obs=prev_obs,
|
| 190 |
+
)
|
| 191 |
+
examples.append({"prompt": prompt, "response": action})
|
| 192 |
+
|
| 193 |
+
# Simulate observation update
|
| 194 |
+
budget -= 1
|
| 195 |
+
cmd = action.split()[0].lower()
|
| 196 |
+
|
| 197 |
+
if cmd == "inspect_dataset":
|
| 198 |
+
t = rng.choice(INSPECT_OBS_TEMPLATES)
|
| 199 |
+
dist = f"class 0: {p['maj_class']}, class 1: {p['min_class']}"
|
| 200 |
+
prev_obs = t.format(
|
| 201 |
+
rows=rows, cols=p["cols"], dups=p["dups"],
|
| 202 |
+
col=p["missing_col"], missing=missing_remaining, dist=dist,
|
| 203 |
+
)
|
| 204 |
+
elif cmd == "inspect_model":
|
| 205 |
+
t = rng.choice(INSPECT_MODEL_TEMPLATES)
|
| 206 |
+
p0 = round(rng.uniform(0.55, 0.75), 3)
|
| 207 |
+
r0 = round(rng.uniform(0.55, 0.75), 3)
|
| 208 |
+
prev_obs = t.format(
|
| 209 |
+
acc=acc, target=p["target"],
|
| 210 |
+
p0=p0, r0=r0, f0=round(2*p0*r0/(p0+r0+1e-9), 3),
|
| 211 |
+
p1=p0, r1=r0, f1=round(2*p0*r0/(p0+r0+1e-9), 3),
|
| 212 |
+
)
|
| 213 |
+
elif cmd == "query_cleaner":
|
| 214 |
+
t = rng.choice(CLEANER_OBS_TEMPLATES)
|
| 215 |
+
session = f"cleaner:{seed:08x}"
|
| 216 |
+
prev_obs = t.format(
|
| 217 |
+
n=missing_remaining, col=p["missing_col"],
|
| 218 |
+
mean=p["mean_val"], dups=p["dups"], typos=rng.randint(2, 8),
|
| 219 |
+
)
|
| 220 |
+
elif cmd == "query_balancer":
|
| 221 |
+
t = rng.choice(BALANCER_OBS_TEMPLATES)
|
| 222 |
+
session = f"balancer:{seed:08x}"
|
| 223 |
+
ratio = round(p["min_class"] / max(p["maj_class"], 1), 2)
|
| 224 |
+
prev_obs = t.format(
|
| 225 |
+
min_c=p["min_class"], maj_c=p["maj_class"], ratio=ratio
|
| 226 |
+
)
|
| 227 |
+
elif cmd == "query_augmenter":
|
| 228 |
+
session = f"augmenter:{seed:08x}"
|
| 229 |
+
cls = action.split()[1] if len(action.split()) > 1 else "0"
|
| 230 |
+
n_synth = rng.randint(5, 25)
|
| 231 |
+
prev_obs = (
|
| 232 |
+
f"=== Augmenter Recommendations ===\n"
|
| 233 |
+
f"[1] Synthesize {n_synth} samples for class {cls} via SMOTE\n"
|
| 234 |
+
f" type=augment_class impact=+0.040 confidence=0.72"
|
| 235 |
+
)
|
| 236 |
+
elif cmd == "query_analyst":
|
| 237 |
+
budget -= 1 # costs 1 extra
|
| 238 |
+
t = rng.choice(ANALYST_OBS_TEMPLATES)
|
| 239 |
+
imb = round(rng.uniform(0.3, 0.8), 2)
|
| 240 |
+
miss = round(rng.uniform(0.1, 0.5), 2)
|
| 241 |
+
gap = round(p["target"] - acc, 4)
|
| 242 |
+
prev_obs = t.format(imb=imb, miss=miss, gap=gap, budget=budget)
|
| 243 |
+
elif cmd == "query_validator":
|
| 244 |
+
budget -= 1 # costs 2
|
| 245 |
+
prev_obs = (
|
| 246 |
+
"=== Validator Report (costs 2 budget) ===\n"
|
| 247 |
+
f" [WARNING] [{p['missing_col']}] rule=no_missing "
|
| 248 |
+
f"count={missing_remaining}\n"
|
| 249 |
+
f" Column '{p['missing_col']}' has {missing_remaining} missing values."
|
| 250 |
+
)
|
| 251 |
+
elif cmd == "apply":
|
| 252 |
+
rec_id = int(action.split()[1]) if len(action.split()) > 1 else 1
|
| 253 |
+
t = rng.choice(APPLY_OBS_TEMPLATES)
|
| 254 |
+
was_missing = missing_remaining
|
| 255 |
+
missing_remaining = max(0, missing_remaining - p["n_missing"])
|
| 256 |
+
quality = min(1.0, quality + rng.uniform(0.10, 0.35))
|
| 257 |
+
quality = round(quality, 4)
|
| 258 |
+
prev_obs = t.format(
|
| 259 |
+
n=p["n_missing"], col=p["missing_col"], mean=p["mean_val"],
|
| 260 |
+
remaining=missing_remaining, was=was_missing,
|
| 261 |
+
rows=rows, orig=p["rows"], pct=rows/p["rows"]*100,
|
| 262 |
+
dups=p["dups"], quality=quality, budget=budget,
|
| 263 |
+
)
|
| 264 |
+
elif cmd == "reject":
|
| 265 |
+
prev_obs = f"Recommendation {action.split()[1] if len(action.split())>1 else 1} rejected."
|
| 266 |
+
elif cmd == "validate":
|
| 267 |
+
if validate_left > 0:
|
| 268 |
+
acc = min(1.0, acc + rng.uniform(0.05, 0.35))
|
| 269 |
+
acc = round(acc, 4)
|
| 270 |
+
lr_acc = round(min(1.0, acc + rng.uniform(-0.03, 0.03)), 4)
|
| 271 |
+
validate_left -= 1
|
| 272 |
+
t = rng.choice(VALIDATE_OBS_TEMPLATES)
|
| 273 |
+
status = "HIT v" if acc >= p["target"] else "Not yet"
|
| 274 |
+
pv = round(rng.uniform(0.75, 0.98), 3)
|
| 275 |
+
rv = round(rng.uniform(0.75, 0.98), 3)
|
| 276 |
+
prev_obs = t.format(
|
| 277 |
+
acc=acc, lr_acc=lr_acc, target=p["target"], status=status,
|
| 278 |
+
p=pv, r=rv, f=round(2*pv*rv/(pv+rv+1e-9), 3),
|
| 279 |
+
)
|
| 280 |
+
else:
|
| 281 |
+
prev_obs = "Validate on cooldown. Take 2 more action(s) before validating again."
|
| 282 |
+
elif cmd == "submit":
|
| 283 |
+
break
|
| 284 |
+
|
| 285 |
+
return examples
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
# ── Strategy sequences ────────────────────────────────────────────────────────
|
| 289 |
+
|
| 290 |
+
STRATEGIES = {
|
| 291 |
+
"minimal_clean": ["inspect_dataset", "query_cleaner", "apply 1", "apply 2", "inspect_dataset", "validate", "submit"],
|
| 292 |
+
"inspect_model_first": ["inspect_dataset", "inspect_model", "query_cleaner", "apply 1", "inspect_dataset", "validate", "submit"],
|
| 293 |
+
"clean_then_balance": ["inspect_dataset", "query_cleaner", "apply 1", "apply 2", "query_balancer", "apply 1", "inspect_dataset", "validate", "submit"],
|
| 294 |
+
"reject_then_apply": ["inspect_dataset", "query_cleaner", "reject 1", "apply 2", "inspect_dataset", "validate", "submit"],
|
| 295 |
+
"baseline_validate_first": ["inspect_dataset", "validate", "query_cleaner", "apply 1", "inspect_dataset", "validate", "submit"],
|
| 296 |
+
"augment_path": ["inspect_dataset", "query_cleaner", "apply 1", "query_augmenter 0", "apply 1", "inspect_dataset", "validate", "submit"],
|
| 297 |
+
"with_validator": ["inspect_dataset", "query_validator", "query_cleaner", "apply 1", "inspect_dataset", "validate", "submit"],
|
| 298 |
+
"deep_clean_requery": ["inspect_dataset", "query_cleaner", "apply 1", "apply 2", "query_cleaner", "apply 1", "inspect_dataset", "validate", "submit"],
|
| 299 |
+
"fast_submit": ["query_cleaner", "apply 1", "apply 2", "inspect_dataset", "submit"],
|
| 300 |
+
"balance_heavy": ["inspect_dataset", "query_balancer", "apply 1", "query_cleaner", "apply 1", "inspect_dataset", "validate", "submit"],
|
| 301 |
+
"reject_requery": ["inspect_dataset", "query_cleaner", "reject 1", "reject 2", "query_cleaner", "apply 1", "inspect_dataset", "validate", "submit"],
|
| 302 |
+
"multi_augment": ["inspect_dataset", "query_cleaner", "apply 1", "query_augmenter 1", "apply 1", "inspect_dataset", "validate", "submit"],
|
| 303 |
+
"model_then_balance": ["inspect_model", "query_balancer", "apply 1", "inspect_dataset", "validate", "submit"],
|
| 304 |
+
"full_pipeline": ["inspect_dataset", "inspect_model", "query_cleaner", "apply 1", "query_balancer", "apply 1", "query_augmenter 0", "apply 1", "inspect_dataset", "validate", "submit"],
|
| 305 |
+
"suboptimal_no_validate": ["inspect_dataset", "query_cleaner", "apply 1", "submit"],
|
| 306 |
+
"inspect_only_submit": ["inspect_dataset", "inspect_model", "submit"],
|
| 307 |
+
"reject_all_then_requery": ["inspect_dataset", "query_cleaner", "reject 1", "reject 2", "query_balancer", "apply 1", "inspect_dataset", "validate", "submit"],
|
| 308 |
+
"apply3_then_validate": ["inspect_dataset", "query_cleaner", "apply 1", "apply 2", "query_balancer", "apply 1", "query_augmenter 0", "apply 1", "inspect_dataset", "validate", "submit"],
|
| 309 |
+
# NEW: analyst-led strategies
|
| 310 |
+
"analyst_led_clean": ["query_analyst", "inspect_dataset", "query_cleaner", "apply 1", "apply 2", "validate", "submit"],
|
| 311 |
+
"analyst_led_balance": ["query_analyst", "query_balancer", "apply 1", "query_cleaner", "apply 1", "validate", "submit"],
|
| 312 |
+
"analyst_full_pipeline": ["query_analyst", "inspect_dataset", "inspect_model", "query_cleaner", "apply 1", "query_balancer", "apply 1", "validate", "submit"],
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def generate_sft_data(output_file: str = "sft_data.jsonl", seeds_per_combo: int = 15):
|
| 317 |
+
sft_examples = []
|
| 318 |
+
|
| 319 |
+
print(f"Generating SFT data: {len(STRATEGIES)} strategies × {len(TASKS)} tasks × {seeds_per_combo} seeds")
|
| 320 |
+
|
| 321 |
+
for strategy_name, sequence in STRATEGIES.items():
|
| 322 |
+
strategy_examples = []
|
| 323 |
+
for task in TASKS:
|
| 324 |
+
for seed in range(seeds_per_combo):
|
| 325 |
+
episode = build_episode(task, seed, sequence)
|
| 326 |
+
strategy_examples.extend(episode)
|
| 327 |
+
sft_examples.extend(strategy_examples)
|
| 328 |
+
print(f" {strategy_name:<30} +{len(strategy_examples)} examples")
|
| 329 |
+
|
| 330 |
+
rng.shuffle(sft_examples)
|
| 331 |
+
|
| 332 |
+
out_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), output_file)
|
| 333 |
+
with open(out_path, "w", encoding="utf-8") as f:
|
| 334 |
+
for ex in sft_examples:
|
| 335 |
+
f.write(json.dumps(ex) + "\n")
|
| 336 |
+
|
| 337 |
+
# Diversity report
|
| 338 |
+
from collections import Counter
|
| 339 |
+
responses = [ex["response"] for ex in sft_examples]
|
| 340 |
+
unique_cmds = set(responses)
|
| 341 |
+
|
| 342 |
+
print(f"\n{'='*55}")
|
| 343 |
+
print(f"Total examples: {len(sft_examples)}")
|
| 344 |
+
print(f"Unique commands: {len(unique_cmds)}")
|
| 345 |
+
print(f"Unique prompts: {len(set(ex['prompt'] for ex in sft_examples))}")
|
| 346 |
+
print(f"\nResponse distribution:")
|
| 347 |
+
for cmd, cnt in Counter(responses).most_common():
|
| 348 |
+
pct = cnt / len(responses) * 100
|
| 349 |
+
bar = "#" * int(pct / 2)
|
| 350 |
+
flag = " ← DOMINANT" if pct > 25 else ""
|
| 351 |
+
print(f" {cmd:<32} {cnt:>5} ({pct:5.1f}%) {bar}{flag}")
|
| 352 |
+
|
| 353 |
+
print(f"\nOutput: {out_path}")
|
| 354 |
+
print("✓ SFT generation complete (no sklearn, instant).")
|
| 355 |
+
return sft_examples
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
if __name__ == "__main__":
|
| 359 |
+
generate_sft_data()
|
test_features_smoke.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""Smoke test: verify all 5 new features work end-to-end."""
|
| 3 |
+
import sys, os
|
| 4 |
+
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
| 5 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 6 |
+
|
| 7 |
+
from models import DataCentricAction
|
| 8 |
+
from server.data_centric_environment import DataCentricEnvironment
|
| 9 |
+
|
| 10 |
+
env = DataCentricEnvironment()
|
| 11 |
+
obs = env.reset(task='task_1_easy', seed=7)
|
| 12 |
+
print(f"Reset OK. Budget: {obs.budget_remaining}, Baseline: {obs.baseline_accuracy:.4f}")
|
| 13 |
+
print()
|
| 14 |
+
|
| 15 |
+
# ─── Feature 3: query_analyst ────────────────────────────────────────────────
|
| 16 |
+
print("=" * 60)
|
| 17 |
+
print("TEST 1: query_analyst (meta-specialist, costs 1 budget)")
|
| 18 |
+
print("=" * 60)
|
| 19 |
+
budget_before = obs.budget_remaining
|
| 20 |
+
obs = env.step(DataCentricAction(message='query_analyst'))
|
| 21 |
+
budget_after = obs.budget_remaining
|
| 22 |
+
print(obs.response)
|
| 23 |
+
print(f"\n[BUDGET CHECK] Before={budget_before}, After={budget_after}, Diff={budget_before - budget_after}")
|
| 24 |
+
assert "DIAGNOSIS" in obs.response, "FAIL: no DIAGNOSIS section"
|
| 25 |
+
assert "RECOMMENDED PLAN" in obs.response, "FAIL: no RECOMMENDED PLAN section"
|
| 26 |
+
assert budget_before - budget_after == 2, f"FAIL: should cost 2 total (1 cmd + 1 analyst), got {budget_before - budget_after}"
|
| 27 |
+
print("PASS: query_analyst works")
|
| 28 |
+
|
| 29 |
+
# ─── Feature 1: Smarter specialists ─────────────────────────────────────────
|
| 30 |
+
print()
|
| 31 |
+
print("=" * 60)
|
| 32 |
+
print("TEST 2: query_cleaner (smarter specialists with reasoning)")
|
| 33 |
+
print("=" * 60)
|
| 34 |
+
obs = env.step(DataCentricAction(message='query_cleaner'))
|
| 35 |
+
print(obs.response)
|
| 36 |
+
# Check for statistical reasoning markers
|
| 37 |
+
has_reasoning = any(kw in obs.response for kw in ["skew", "Risk:", "Reason:", "median", "mean", "%"])
|
| 38 |
+
assert has_reasoning, "FAIL: no statistical reasoning found in cleaner output"
|
| 39 |
+
print("PASS: smarter specialists working (statistical reasoning present)")
|
| 40 |
+
|
| 41 |
+
# ─── Feature 5: Drift detection ──────────────────────────────────────────────
|
| 42 |
+
print()
|
| 43 |
+
print("=" * 60)
|
| 44 |
+
print("TEST 3: apply 1 (drift detection after apply)")
|
| 45 |
+
print("=" * 60)
|
| 46 |
+
obs = env.step(DataCentricAction(message='apply 1'))
|
| 47 |
+
print(obs.response)
|
| 48 |
+
has_drift = "Distribution drift" in obs.response or "drift" in obs.response.lower()
|
| 49 |
+
assert has_drift, "FAIL: no drift information in apply response"
|
| 50 |
+
print("PASS: drift detection working")
|
| 51 |
+
|
| 52 |
+
# ─── Feature 2 + 4: Dual classifier + Feature importance ───────────────────
|
| 53 |
+
print()
|
| 54 |
+
print("=" * 60)
|
| 55 |
+
print("TEST 4: validate (dual classifier + feature importance)")
|
| 56 |
+
print("=" * 60)
|
| 57 |
+
obs = env.step(DataCentricAction(message='validate'))
|
| 58 |
+
print(obs.response)
|
| 59 |
+
assert "RF Accuracy" in obs.response, "FAIL: no RF Accuracy"
|
| 60 |
+
assert "LR Accuracy" in obs.response, "FAIL: no LR Accuracy"
|
| 61 |
+
assert "Agreement" in obs.response, "FAIL: no Agreement signal"
|
| 62 |
+
has_feat_imp = "Feature importance" in obs.response
|
| 63 |
+
print(f"Feature importance shown: {has_feat_imp}")
|
| 64 |
+
print("PASS: dual classifier + agreement signal working")
|
| 65 |
+
|
| 66 |
+
# ─── Feature 4: Feature importance in inspect_model ─────────────────────────
|
| 67 |
+
print()
|
| 68 |
+
print("=" * 60)
|
| 69 |
+
print("TEST 5: inspect_model (RF + LR + feature importance)")
|
| 70 |
+
print("=" * 60)
|
| 71 |
+
obs = env.step(DataCentricAction(message='inspect_model'))
|
| 72 |
+
print(obs.response)
|
| 73 |
+
assert "RF Accuracy" in obs.response, "FAIL: no RF Accuracy in inspect_model"
|
| 74 |
+
assert "LR Accuracy" in obs.response, "FAIL: no LR Accuracy in inspect_model"
|
| 75 |
+
print("PASS: inspect_model shows dual classifier")
|
| 76 |
+
|
| 77 |
+
print()
|
| 78 |
+
print("=" * 60)
|
| 79 |
+
print("ALL 5 FEATURES VERIFIED OK")
|
| 80 |
+
print("=" * 60)
|
tests/__init__.py
ADDED
|
File without changes
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys, os
|
| 2 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
tests/test_environment.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unit tests for anti-exploit and environment stability.
|
| 3 |
+
|
| 4 |
+
Tests that the core safety invariants hold:
|
| 5 |
+
- ground truth never mutates
|
| 6 |
+
- budget is enforced
|
| 7 |
+
- validate calls are limited
|
| 8 |
+
- undo works correctly
|
| 9 |
+
|
| 10 |
+
Run with: pytest tests/test_environment.py -v
|
| 11 |
+
"""
|
| 12 |
+
import pytest
|
| 13 |
+
import sys, os
|
| 14 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 15 |
+
|
| 16 |
+
from models import DataCentricAction
|
| 17 |
+
from server.data_centric_environment import DataCentricEnvironment
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
| 21 |
+
|
| 22 |
+
def make_env(task="task_0_tutorial", seed=42) -> tuple:
|
| 23 |
+
"""Return (env, reset_obs)."""
|
| 24 |
+
env = DataCentricEnvironment()
|
| 25 |
+
obs = env.reset(task=task, seed=seed)
|
| 26 |
+
return env, obs
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def step(env, cmd: str):
|
| 30 |
+
return env.step(DataCentricAction(message=cmd))
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# ── Ground truth immutability ─────────────────────────────────────────────────
|
| 34 |
+
|
| 35 |
+
class TestGroundTruth:
|
| 36 |
+
|
| 37 |
+
def test_ground_truth_unchanged_after_reset(self):
|
| 38 |
+
env, _ = make_env()
|
| 39 |
+
gt_before = env._ground_truth.copy()
|
| 40 |
+
env.reset(task="task_0_tutorial", seed=123)
|
| 41 |
+
# After re-reset, a NEW ground truth is loaded — old one doesn't matter
|
| 42 |
+
assert env._ground_truth is not None
|
| 43 |
+
|
| 44 |
+
def test_ground_truth_unchanged_after_query(self):
|
| 45 |
+
env, _ = make_env()
|
| 46 |
+
gt_before = env._ground_truth.copy()
|
| 47 |
+
step(env, "query_cleaner")
|
| 48 |
+
assert env._ground_truth.equals(gt_before), "GT mutated after query_cleaner"
|
| 49 |
+
|
| 50 |
+
def test_ground_truth_unchanged_after_inspect(self):
|
| 51 |
+
env, _ = make_env()
|
| 52 |
+
gt_before = env._ground_truth.copy()
|
| 53 |
+
step(env, "inspect_dataset")
|
| 54 |
+
assert env._ground_truth.equals(gt_before), "GT mutated after inspect_dataset"
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ── Budget enforcement ────────────────────────────────────────────────────────
|
| 58 |
+
|
| 59 |
+
class TestBudget:
|
| 60 |
+
|
| 61 |
+
def test_budget_decreases_each_step(self):
|
| 62 |
+
env, obs = make_env()
|
| 63 |
+
budget_start = obs.budget_remaining
|
| 64 |
+
obs2 = step(env, "inspect_dataset")
|
| 65 |
+
assert obs2.budget_remaining < budget_start
|
| 66 |
+
|
| 67 |
+
def test_done_after_budget_exhausted(self):
|
| 68 |
+
env, obs = make_env()
|
| 69 |
+
budget = obs.budget_remaining
|
| 70 |
+
last_obs = obs
|
| 71 |
+
for _ in range(budget + 5):
|
| 72 |
+
if last_obs.done:
|
| 73 |
+
break
|
| 74 |
+
last_obs = step(env, "inspect_dataset")
|
| 75 |
+
assert last_obs.done, "Episode should be done after budget exhausted"
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ── Validate calls ────────────────────────────────────────────────────────────
|
| 79 |
+
|
| 80 |
+
class TestValidateCalls:
|
| 81 |
+
|
| 82 |
+
def test_validate_calls_start_at_3(self):
|
| 83 |
+
env, obs = make_env()
|
| 84 |
+
assert obs.validate_calls_remaining == 3
|
| 85 |
+
|
| 86 |
+
def test_validate_call_decrements(self):
|
| 87 |
+
env, obs = make_env()
|
| 88 |
+
obs2 = step(env, "validate")
|
| 89 |
+
# Either decrement or cooldown message — either way calls consumed
|
| 90 |
+
assert obs2.validate_calls_remaining <= 3
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# ── Undo ─────────────────────────────────────────────────────────────────────
|
| 94 |
+
|
| 95 |
+
class TestUndo:
|
| 96 |
+
|
| 97 |
+
def test_undo_without_history_returns_response(self):
|
| 98 |
+
"""Undo with no history should return a message, not crash."""
|
| 99 |
+
env, _ = make_env()
|
| 100 |
+
obs = step(env, "undo")
|
| 101 |
+
assert obs.response is not None
|
| 102 |
+
assert len(obs.response) > 0
|
| 103 |
+
|
| 104 |
+
def test_undo_after_apply_restores_state(self):
|
| 105 |
+
"""After apply+undo, working copy should match pre-apply state."""
|
| 106 |
+
env, _ = make_env()
|
| 107 |
+
step(env, "query_cleaner")
|
| 108 |
+
wc_before_apply = env._working_copy.copy()
|
| 109 |
+
step(env, "apply 1")
|
| 110 |
+
step(env, "undo")
|
| 111 |
+
# After undo, working copy should be restored
|
| 112 |
+
assert env._working_copy.shape == wc_before_apply.shape, (
|
| 113 |
+
f"Shape mismatch after undo: {env._working_copy.shape} vs {wc_before_apply.shape}"
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# ── Snapshot stack ────────────────────────────────────────────────────────────
|
| 118 |
+
|
| 119 |
+
class TestSnapshotStack:
|
| 120 |
+
|
| 121 |
+
def test_max_3_snapshots(self):
|
| 122 |
+
env, _ = make_env()
|
| 123 |
+
step(env, "query_cleaner")
|
| 124 |
+
# Apply 4 times — stack should cap at 3
|
| 125 |
+
for i in range(1, 5):
|
| 126 |
+
step(env, f"apply {i}")
|
| 127 |
+
step(env, "query_cleaner") # re-query for fresh recs
|
| 128 |
+
assert len(env._dataset_history) <= env._max_history
|
| 129 |
+
|
| 130 |
+
def test_snapshot_cleared_on_reset(self):
|
| 131 |
+
env, _ = make_env()
|
| 132 |
+
step(env, "query_cleaner")
|
| 133 |
+
step(env, "apply 1")
|
| 134 |
+
env.reset(task="task_0_tutorial", seed=99)
|
| 135 |
+
assert len(env._dataset_history) == 0
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
# ── Reward sanity ─────────────────────────────────────────────────────────────
|
| 139 |
+
|
| 140 |
+
class TestRewardSanity:
|
| 141 |
+
|
| 142 |
+
def test_unknown_command_gives_penalty(self):
|
| 143 |
+
env, _ = make_env()
|
| 144 |
+
obs = step(env, "blorp_invalid_command_xyz")
|
| 145 |
+
assert obs.reward <= 0.0, (
|
| 146 |
+
f"Unknown command should not give positive reward, got {obs.reward}"
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
def test_reward_within_range(self):
|
| 150 |
+
env, _ = make_env()
|
| 151 |
+
from server.grader import REWARD_MIN, REWARD_MAX
|
| 152 |
+
for cmd in ["inspect_dataset", "inspect_model", "query_cleaner",
|
| 153 |
+
"query_balancer", "validate"]:
|
| 154 |
+
obs = step(env, cmd)
|
| 155 |
+
r = obs.reward
|
| 156 |
+
assert REWARD_MIN - 0.01 <= r <= REWARD_MAX + 0.01, (
|
| 157 |
+
f"Command '{cmd}' gave out-of-range reward {r}"
|
| 158 |
+
)
|
tests/test_grader.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unit tests for server/grader.py
|
| 3 |
+
|
| 4 |
+
Tests every reward component individually plus the range clamp.
|
| 5 |
+
Run with: pytest tests/test_grader.py -v
|
| 6 |
+
"""
|
| 7 |
+
import importlib.util
|
| 8 |
+
import sys
|
| 9 |
+
import os
|
| 10 |
+
import pytest
|
| 11 |
+
import pandas as pd
|
| 12 |
+
|
| 13 |
+
# Import grader directly (avoids server/__init__.py → openenv-core chain)
|
| 14 |
+
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 15 |
+
_GRADER = os.path.join(_ROOT, "server", "grader.py")
|
| 16 |
+
spec = importlib.util.spec_from_file_location("grader", _GRADER)
|
| 17 |
+
_mod = importlib.util.module_from_spec(spec)
|
| 18 |
+
spec.loader.exec_module(_mod)
|
| 19 |
+
|
| 20 |
+
REWARD_MAX = _mod.REWARD_MAX
|
| 21 |
+
REWARD_MIN = _mod.REWARD_MIN
|
| 22 |
+
compute_accuracy_reward = _mod.compute_accuracy_reward
|
| 23 |
+
compute_efficiency_reward = _mod.compute_efficiency_reward
|
| 24 |
+
compute_preservation_reward = _mod.compute_preservation_reward
|
| 25 |
+
compute_process_reward = _mod.compute_process_reward
|
| 26 |
+
compute_step_reward = _mod.compute_step_reward
|
| 27 |
+
compute_total_reward = _mod.compute_total_reward
|
| 28 |
+
compute_lightweight_score = _mod.compute_lightweight_score
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# ── Accuracy reward ───────────────────────────────────────────────────────────
|
| 32 |
+
|
| 33 |
+
class TestAccuracyReward:
|
| 34 |
+
|
| 35 |
+
def test_improvement_positive(self):
|
| 36 |
+
r = compute_accuracy_reward(0.70, 0.62, 0.62, 0.80)
|
| 37 |
+
assert r > 0, f"Improvement should give positive reward, got {r}"
|
| 38 |
+
|
| 39 |
+
def test_regression_negative(self):
|
| 40 |
+
r = compute_accuracy_reward(0.60, 0.70, 0.62, 0.80)
|
| 41 |
+
assert r < 0, f"Regression should give negative reward, got {r}"
|
| 42 |
+
|
| 43 |
+
def test_no_change_zero(self):
|
| 44 |
+
r = compute_accuracy_reward(0.65, 0.65, 0.62, 0.80)
|
| 45 |
+
assert r == 0.0
|
| 46 |
+
|
| 47 |
+
def test_submit_success_bonus(self):
|
| 48 |
+
r = compute_accuracy_reward(0.80, 0.75, 0.62, 0.80, is_submit=True)
|
| 49 |
+
assert r > 0.5, f"Submit success should add bonus, got {r}"
|
| 50 |
+
|
| 51 |
+
def test_submit_fail_partial_credit(self):
|
| 52 |
+
"""Halfway to target should give some credit."""
|
| 53 |
+
# baseline=0.62, target=0.80, current=0.71 → 50% of the way there
|
| 54 |
+
r = compute_accuracy_reward(0.71, 0.70, 0.62, 0.80, is_submit=True)
|
| 55 |
+
# Current is at 0.71, previous was 0.70 → small improvement, partial credit
|
| 56 |
+
assert r > 0, f"Partial progress at submit should give credit, got {r}"
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# ── Preservation reward ───────────────────────────────────────────────────────
|
| 60 |
+
|
| 61 |
+
class TestPreservationReward:
|
| 62 |
+
|
| 63 |
+
def test_above_90_bonus(self):
|
| 64 |
+
r = compute_preservation_reward(97, 100)
|
| 65 |
+
assert r > 0
|
| 66 |
+
|
| 67 |
+
def test_below_90_zero_or_neg(self):
|
| 68 |
+
r = compute_preservation_reward(85, 100)
|
| 69 |
+
assert r <= 0.02 # at best neutral at 85%
|
| 70 |
+
|
| 71 |
+
def test_below_50_catastrophic(self):
|
| 72 |
+
r = compute_preservation_reward(40, 100)
|
| 73 |
+
assert r <= -0.40, f"Expected catastrophic penalty, got {r}"
|
| 74 |
+
|
| 75 |
+
def test_full_preservation(self):
|
| 76 |
+
r = compute_preservation_reward(100, 100)
|
| 77 |
+
assert r == 0.05
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# ── Process reward ────────────────────────────────────────────────────────────
|
| 81 |
+
|
| 82 |
+
class TestProcessReward:
|
| 83 |
+
|
| 84 |
+
def test_query_after_inspect_rewarded(self):
|
| 85 |
+
history = ["inspect_dataset"]
|
| 86 |
+
r = compute_process_reward(history, "query_cleaner")
|
| 87 |
+
assert r > 0
|
| 88 |
+
|
| 89 |
+
def test_apply_without_query_penalized(self):
|
| 90 |
+
history = ["inspect_dataset", "inspect_model"]
|
| 91 |
+
r = compute_process_reward(history, "apply 1")
|
| 92 |
+
assert r < 0
|
| 93 |
+
|
| 94 |
+
def test_apply_after_query_rewarded(self):
|
| 95 |
+
history = ["inspect_dataset", "query_cleaner"]
|
| 96 |
+
r = compute_process_reward(history, "apply 1")
|
| 97 |
+
assert r > 0
|
| 98 |
+
|
| 99 |
+
def test_submit_without_validate_penalized(self):
|
| 100 |
+
history = ["inspect_dataset", "query_cleaner", "apply 1"]
|
| 101 |
+
r = compute_process_reward(history, "submit")
|
| 102 |
+
assert r < 0
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# ── Step reward ───────────────────────────────────────────────────────────────
|
| 106 |
+
|
| 107 |
+
class TestStepReward:
|
| 108 |
+
|
| 109 |
+
def test_quality_improvement_positive(self):
|
| 110 |
+
r = compute_step_reward("apply 1", quality_before=0.5, quality_after=0.7,
|
| 111 |
+
rows_preserved_after=0.97)
|
| 112 |
+
assert r > 0
|
| 113 |
+
|
| 114 |
+
def test_quality_degradation_negative(self):
|
| 115 |
+
r = compute_step_reward("apply 1", quality_before=0.7, quality_after=0.4,
|
| 116 |
+
rows_preserved_after=0.97)
|
| 117 |
+
assert r < 0
|
| 118 |
+
|
| 119 |
+
def test_non_apply_zero(self):
|
| 120 |
+
r = compute_step_reward("validate", quality_before=0.5, quality_after=0.7,
|
| 121 |
+
rows_preserved_after=0.97)
|
| 122 |
+
assert r == 0.0
|
| 123 |
+
|
| 124 |
+
def test_low_preservation_penalty(self):
|
| 125 |
+
r = compute_step_reward("apply 1", quality_before=0.5, quality_after=0.6,
|
| 126 |
+
rows_preserved_after=0.75)
|
| 127 |
+
# Row preservation penalty should reduce reward
|
| 128 |
+
r_without = compute_step_reward("apply 1", quality_before=0.5, quality_after=0.6,
|
| 129 |
+
rows_preserved_after=0.97)
|
| 130 |
+
assert r < r_without
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
# ── Total reward range ────────────────────────────────────────────────────────
|
| 134 |
+
|
| 135 |
+
class TestRewardRange:
|
| 136 |
+
|
| 137 |
+
def test_within_declared_range(self):
|
| 138 |
+
"""All reward combinations must stay within [-1.0, 1.0]."""
|
| 139 |
+
test_cases = [
|
| 140 |
+
(0.5, 0.1, 0.05, 0.2, 0.1),
|
| 141 |
+
(-0.8, -0.1, -0.4, -0.05, -0.2),
|
| 142 |
+
(1.0, 0.2, 0.05, 0.2, 0.15), # might need clamping
|
| 143 |
+
(-1.5, -0.2, -0.4, -0.05, -0.3), # definitely needs clamping
|
| 144 |
+
]
|
| 145 |
+
for acc, proc, pres, eff, step in test_cases:
|
| 146 |
+
r = compute_total_reward(acc, proc, pres, eff, step)
|
| 147 |
+
assert REWARD_MIN <= r <= REWARD_MAX, (
|
| 148 |
+
f"Reward {r} out of [{REWARD_MIN}, {REWARD_MAX}] "
|
| 149 |
+
f"for inputs acc={acc} proc={proc} pres={pres}"
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
def test_clamping_applied(self):
|
| 153 |
+
"""Extreme inputs should be clamped, not crash."""
|
| 154 |
+
r = compute_total_reward(10.0, 5.0, 5.0)
|
| 155 |
+
assert r == REWARD_MAX
|
| 156 |
+
|
| 157 |
+
r = compute_total_reward(-10.0, -5.0, -5.0)
|
| 158 |
+
assert r == REWARD_MIN
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
# ── Lightweight quality score ─────────────────────────────────────────────────
|
| 162 |
+
|
| 163 |
+
class TestLightweightScore:
|
| 164 |
+
|
| 165 |
+
def _make_df(self, n_rows=10, n_missing=0, n_dups=0):
|
| 166 |
+
"""Create a minimal test dataframe."""
|
| 167 |
+
df = pd.DataFrame({
|
| 168 |
+
"feature_0": [float(i) for i in range(n_rows)],
|
| 169 |
+
"target": [i % 2 for i in range(n_rows)],
|
| 170 |
+
})
|
| 171 |
+
if n_missing:
|
| 172 |
+
df.loc[:n_missing - 1, "feature_0"] = float("nan")
|
| 173 |
+
if n_dups:
|
| 174 |
+
df = pd.concat([df, df.iloc[:n_dups]], ignore_index=True)
|
| 175 |
+
return df
|
| 176 |
+
|
| 177 |
+
def test_clean_df_high_score(self):
|
| 178 |
+
df = self._make_df()
|
| 179 |
+
score = compute_lightweight_score(df, df.copy(), len(df),
|
| 180 |
+
{"feature_0": {"expected_dtype": "float64"}})
|
| 181 |
+
assert score >= 0.80
|
| 182 |
+
|
| 183 |
+
def test_many_missing_low_score(self):
|
| 184 |
+
df = self._make_df(n_missing=8)
|
| 185 |
+
score = compute_lightweight_score(df, df.copy(), 10,
|
| 186 |
+
{"feature_0": {"expected_dtype": "float64"}},
|
| 187 |
+
initial_missing=8)
|
| 188 |
+
assert score < 0.70
|
| 189 |
+
|
| 190 |
+
def test_score_in_range(self):
|
| 191 |
+
df = self._make_df(n_missing=3, n_dups=2)
|
| 192 |
+
score = compute_lightweight_score(df, df.copy(), 10,
|
| 193 |
+
{"feature_0": {"expected_dtype": "float64"}},
|
| 194 |
+
initial_missing=3)
|
| 195 |
+
assert 0.0 <= score <= 1.0
|
train_colab.ipynb
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"nbformat": 4,
|
| 3 |
+
"nbformat_minor": 5,
|
| 4 |
+
"metadata": {
|
| 5 |
+
"colab": {"provenance": [], "gpuType": "T4"},
|
| 6 |
+
"kernelspec": {"display_name": "Python 3", "name": "python3"},
|
| 7 |
+
"language_info": {"name": "python"},
|
| 8 |
+
"accelerator": "GPU"
|
| 9 |
+
},
|
| 10 |
+
"cells": [
|
| 11 |
+
{
|
| 12 |
+
"cell_type": "markdown",
|
| 13 |
+
"id": "a0",
|
| 14 |
+
"metadata": {},
|
| 15 |
+
"source": [
|
| 16 |
+
"# Data-Centric AI Agent \u2014 Training Notebook\n",
|
| 17 |
+
"**OpenEnv Hackathon 2026**\n\n",
|
| 18 |
+
"Trains a Qwen2.5-3B agent to improve ML datasets using:\n",
|
| 19 |
+
"- **Phase 1**: SFT warmup (~30 min) \u2014 teaches command grammar\n",
|
| 20 |
+
"- **Phase 2**: GRPO training (~3-5 hrs) \u2014 teaches strategy via RL\n",
|
| 21 |
+
"- **Phase 3**: Eval + reward curves\n\n",
|
| 22 |
+
"**Runtime required:** T4 GPU. `Runtime \u2192 Change runtime type \u2192 T4 GPU`"
|
| 23 |
+
]
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
"cell_type": "markdown",
|
| 27 |
+
"id": "a1",
|
| 28 |
+
"metadata": {},
|
| 29 |
+
"source": ["## Step 1 \u2014 Install dependencies (~5 min)"]
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
"cell_type": "code",
|
| 33 |
+
"execution_count": null,
|
| 34 |
+
"id": "b1",
|
| 35 |
+
"metadata": {},
|
| 36 |
+
"outputs": [],
|
| 37 |
+
"source": [
|
| 38 |
+
"!pip install \"unsloth[colab-new] @ git+https://github.com/unslothai/unsloth.git\" --quiet\n",
|
| 39 |
+
"!pip install trl datasets transformers accelerate scikit-learn pandas numpy matplotlib --quiet\n",
|
| 40 |
+
"!pip install \"openenv-core[core]>=0.2.1\" --quiet\n",
|
| 41 |
+
"print('Dependencies installed')"
|
| 42 |
+
]
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
"cell_type": "markdown",
|
| 46 |
+
"id": "a2",
|
| 47 |
+
"metadata": {},
|
| 48 |
+
"source": [
|
| 49 |
+
"## Step 2 \u2014 Mount Google Drive (optional but recommended)\n",
|
| 50 |
+
"Checkpoints save every 50 GRPO steps to Drive. If Colab disconnects, you can resume.\n\n",
|
| 51 |
+
"**Skip this cell if you don't want Drive integration.**"
|
| 52 |
+
]
|
| 53 |
+
},
|
| 54 |
+
{
|
| 55 |
+
"cell_type": "code",
|
| 56 |
+
"execution_count": null,
|
| 57 |
+
"id": "b2",
|
| 58 |
+
"metadata": {},
|
| 59 |
+
"outputs": [],
|
| 60 |
+
"source": [
|
| 61 |
+
"from google.colab import drive\n",
|
| 62 |
+
"drive.mount('/content/drive')\n",
|
| 63 |
+
"print('Drive mounted')"
|
| 64 |
+
]
|
| 65 |
+
},
|
| 66 |
+
{
|
| 67 |
+
"cell_type": "markdown",
|
| 68 |
+
"id": "a3",
|
| 69 |
+
"metadata": {},
|
| 70 |
+
"source": ["## Step 3 \u2014 Clone / update repo + setup"]
|
| 71 |
+
},
|
| 72 |
+
{
|
| 73 |
+
"cell_type": "code",
|
| 74 |
+
"execution_count": null,
|
| 75 |
+
"id": "b3",
|
| 76 |
+
"metadata": {},
|
| 77 |
+
"outputs": [],
|
| 78 |
+
"source": [
|
| 79 |
+
"import os, sys, shutil\n",
|
| 80 |
+
"\n",
|
| 81 |
+
"REPO = 'https://github.com/CelestialWorthyOfHeavenAndEarth/data-centric-env.git'\n",
|
| 82 |
+
"WORK_DIR = '/content/data-centric-env'\n",
|
| 83 |
+
"\n",
|
| 84 |
+
"# Clone or pull latest\n",
|
| 85 |
+
"if os.path.exists(f'{WORK_DIR}/pyproject.toml'):\n",
|
| 86 |
+
" print('Repo exists, pulling latest (includes new features)...')\n",
|
| 87 |
+
" !git -C {WORK_DIR} pull origin main\n",
|
| 88 |
+
"else:\n",
|
| 89 |
+
" if os.path.exists(WORK_DIR):\n",
|
| 90 |
+
" shutil.rmtree(WORK_DIR)\n",
|
| 91 |
+
" !git clone {REPO} {WORK_DIR}\n",
|
| 92 |
+
"\n",
|
| 93 |
+
"os.chdir(WORK_DIR)\n",
|
| 94 |
+
"sys.path.insert(0, WORK_DIR)\n",
|
| 95 |
+
"print('CWD:', os.getcwd())\n",
|
| 96 |
+
"\n",
|
| 97 |
+
"# Show latest commit so we know we have the right version\n",
|
| 98 |
+
"!git log --oneline -3\n",
|
| 99 |
+
"\n",
|
| 100 |
+
"# Install as editable package (fixes relative imports)\n",
|
| 101 |
+
"!pip install -e . --quiet 2>/dev/null || echo 'pip install -e . skipped'\n",
|
| 102 |
+
"\n",
|
| 103 |
+
"# Symlink output dirs to Drive if mounted\n",
|
| 104 |
+
"DRIVE_DIR = '/content/drive/MyDrive/data-centric-training'\n",
|
| 105 |
+
"if os.path.exists('/content/drive/MyDrive'):\n",
|
| 106 |
+
" os.makedirs(DRIVE_DIR, exist_ok=True)\n",
|
| 107 |
+
" for d in ['data-centric-checkpoints', 'data-centric-adapter', 'logs', 'plots']:\n",
|
| 108 |
+
" local = os.path.join(WORK_DIR, d)\n",
|
| 109 |
+
" remote = os.path.join(DRIVE_DIR, d)\n",
|
| 110 |
+
" os.makedirs(remote, exist_ok=True)\n",
|
| 111 |
+
" if not os.path.exists(local):\n",
|
| 112 |
+
" os.symlink(remote, local)\n",
|
| 113 |
+
" print(f' Drive link: {d}')\n",
|
| 114 |
+
" else:\n",
|
| 115 |
+
" print(f' Exists: {d}')\n",
|
| 116 |
+
" print('Checkpoints will save to Drive')\n",
|
| 117 |
+
"else:\n",
|
| 118 |
+
" print('Drive not mounted - checkpoints save locally only')\n",
|
| 119 |
+
"\n",
|
| 120 |
+
"print('Setup complete')"
|
| 121 |
+
]
|
| 122 |
+
},
|
| 123 |
+
{
|
| 124 |
+
"cell_type": "markdown",
|
| 125 |
+
"id": "a35",
|
| 126 |
+
"metadata": {},
|
| 127 |
+
"source": [
|
| 128 |
+
"## Step 3.5 \u2014 Verify all features work\n",
|
| 129 |
+
"Runs a quick smoke test to confirm all 5 new features (query_analyst, smarter specialists,\n",
|
| 130 |
+
"dual classifier, feature importance, drift detection) are connected and working."
|
| 131 |
+
]
|
| 132 |
+
},
|
| 133 |
+
{
|
| 134 |
+
"cell_type": "code",
|
| 135 |
+
"execution_count": null,
|
| 136 |
+
"id": "b35",
|
| 137 |
+
"metadata": {},
|
| 138 |
+
"outputs": [],
|
| 139 |
+
"source": [
|
| 140 |
+
"import os, sys\n",
|
| 141 |
+
"os.chdir('/content/data-centric-env')\n",
|
| 142 |
+
"sys.path.insert(0, '/content/data-centric-env')\n",
|
| 143 |
+
"\n",
|
| 144 |
+
"# Quick inline smoke test (no file needed)\n",
|
| 145 |
+
"from models import DataCentricAction\n",
|
| 146 |
+
"from server.data_centric_environment import DataCentricEnvironment\n",
|
| 147 |
+
"\n",
|
| 148 |
+
"env = DataCentricEnvironment()\n",
|
| 149 |
+
"obs = env.reset(task='task_1_easy', seed=42)\n",
|
| 150 |
+
"\n",
|
| 151 |
+
"checks = {}\n",
|
| 152 |
+
"\n",
|
| 153 |
+
"# 1. query_analyst\n",
|
| 154 |
+
"obs = env.step(DataCentricAction(message='query_analyst'))\n",
|
| 155 |
+
"checks['query_analyst'] = 'DIAGNOSIS' in obs.response and 'RECOMMENDED PLAN' in obs.response\n",
|
| 156 |
+
"\n",
|
| 157 |
+
"# 2. Smarter specialists\n",
|
| 158 |
+
"obs = env.step(DataCentricAction(message='query_cleaner'))\n",
|
| 159 |
+
"checks['smarter_specialists'] = any(k in obs.response for k in ['skew', 'Risk:', 'Reason:', '%'])\n",
|
| 160 |
+
"\n",
|
| 161 |
+
"# 3. Drift detection (apply then check)\n",
|
| 162 |
+
"obs = env.step(DataCentricAction(message='apply 1'))\n",
|
| 163 |
+
"checks['drift_detection'] = 'Distribution drift' in obs.response or 'drift' in obs.response.lower()\n",
|
| 164 |
+
"\n",
|
| 165 |
+
"# 4+5. Dual classifier + feature importance\n",
|
| 166 |
+
"obs = env.step(DataCentricAction(message='validate'))\n",
|
| 167 |
+
"checks['dual_classifier'] = 'RF Accuracy' in obs.response and 'LR Accuracy' in obs.response\n",
|
| 168 |
+
"checks['feature_importance'] = 'Feature importance' in obs.response\n",
|
| 169 |
+
"\n",
|
| 170 |
+
"print('\\n=== Feature Smoke Test ===')\n",
|
| 171 |
+
"all_pass = True\n",
|
| 172 |
+
"for name, passed in checks.items():\n",
|
| 173 |
+
" status = 'PASS' if passed else 'FAIL'\n",
|
| 174 |
+
" if not passed: all_pass = False\n",
|
| 175 |
+
" print(f' {status}: {name}')\n",
|
| 176 |
+
"\n",
|
| 177 |
+
"if all_pass:\n",
|
| 178 |
+
" print('\\nAll 5 features verified OK - ready to train!')\n",
|
| 179 |
+
"else:\n",
|
| 180 |
+
" print('\\nWARNING: Some features failed - check git pull ran correctly')"
|
| 181 |
+
]
|
| 182 |
+
},
|
| 183 |
+
{
|
| 184 |
+
"cell_type": "markdown",
|
| 185 |
+
"id": "a4",
|
| 186 |
+
"metadata": {},
|
| 187 |
+
"source": ["## Step 4 \u2014 Start environment server"]
|
| 188 |
+
},
|
| 189 |
+
{
|
| 190 |
+
"cell_type": "code",
|
| 191 |
+
"execution_count": null,
|
| 192 |
+
"id": "b4",
|
| 193 |
+
"metadata": {},
|
| 194 |
+
"outputs": [],
|
| 195 |
+
"source": [
|
| 196 |
+
"import subprocess, time, requests, os, sys\n",
|
| 197 |
+
"os.chdir('/content/data-centric-env')\n",
|
| 198 |
+
"sys.path.insert(0, '/content/data-centric-env')\n",
|
| 199 |
+
"\n",
|
| 200 |
+
"# Kill any existing server on port 8000\n",
|
| 201 |
+
"!fuser -k 8000/tcp 2>/dev/null || true\n",
|
| 202 |
+
"time.sleep(2)\n",
|
| 203 |
+
"\n",
|
| 204 |
+
"server_proc = subprocess.Popen(\n",
|
| 205 |
+
" ['python', '-m', 'uvicorn', 'server.app:app',\n",
|
| 206 |
+
" '--host', '0.0.0.0', '--port', '8000', '--log-level', 'warning'],\n",
|
| 207 |
+
" stdout=subprocess.PIPE, stderr=subprocess.PIPE\n",
|
| 208 |
+
")\n",
|
| 209 |
+
"\n",
|
| 210 |
+
"for i in range(30):\n",
|
| 211 |
+
" try:\n",
|
| 212 |
+
" if requests.get('http://localhost:8000/health', timeout=2).status_code == 200:\n",
|
| 213 |
+
" print(f'Server ready after {i+1}s')\n",
|
| 214 |
+
" break\n",
|
| 215 |
+
" except:\n",
|
| 216 |
+
" time.sleep(1)\n",
|
| 217 |
+
"else:\n",
|
| 218 |
+
" server_proc.terminate()\n",
|
| 219 |
+
" out, err = server_proc.communicate()\n",
|
| 220 |
+
" print('STDOUT:', out.decode()[-1000:])\n",
|
| 221 |
+
" print('STDERR:', err.decode()[-1000:])\n",
|
| 222 |
+
" raise RuntimeError('Server failed to start in 30s - see output above')"
|
| 223 |
+
]
|
| 224 |
+
},
|
| 225 |
+
{
|
| 226 |
+
"cell_type": "markdown",
|
| 227 |
+
"id": "a5",
|
| 228 |
+
"metadata": {},
|
| 229 |
+
"source": ["## Step 5 \u2014 Generate SFT warmup data"]
|
| 230 |
+
},
|
| 231 |
+
{
|
| 232 |
+
"cell_type": "code",
|
| 233 |
+
"execution_count": null,
|
| 234 |
+
"id": "b5",
|
| 235 |
+
"metadata": {},
|
| 236 |
+
"outputs": [],
|
| 237 |
+
"source": [
|
| 238 |
+
"import os\n",
|
| 239 |
+
"os.chdir('/content/data-centric-env')\n",
|
| 240 |
+
"\n",
|
| 241 |
+
"# Always regenerate if we updated the code (new query_analyst strategies)\n",
|
| 242 |
+
"# Skip only if the file is recent (within last 10 minutes)\n",
|
| 243 |
+
"import time\n",
|
| 244 |
+
"sft_path = 'sft_data.jsonl'\n",
|
| 245 |
+
"regen = True\n",
|
| 246 |
+
"if os.path.exists(sft_path):\n",
|
| 247 |
+
" age_minutes = (time.time() - os.path.getmtime(sft_path)) / 60\n",
|
| 248 |
+
" count = sum(1 for _ in open(sft_path))\n",
|
| 249 |
+
" print(f'sft_data.jsonl exists: {count} examples, {age_minutes:.0f} min old')\n",
|
| 250 |
+
" # Regenerate if old file missing query_analyst strategies (<= 18 strategies)\n",
|
| 251 |
+
" regen = count < 20000 # new version has ~21 strategies\n",
|
| 252 |
+
"\n",
|
| 253 |
+
"if regen:\n",
|
| 254 |
+
" print('Generating SFT data (includes query_analyst strategies)...')\n",
|
| 255 |
+
" !python sft_generator.py\n",
|
| 256 |
+
" print('SFT data generated')\n",
|
| 257 |
+
"else:\n",
|
| 258 |
+
" print('SFT data is up to date - skipping')"
|
| 259 |
+
]
|
| 260 |
+
},
|
| 261 |
+
{
|
| 262 |
+
"cell_type": "markdown",
|
| 263 |
+
"id": "a6",
|
| 264 |
+
"metadata": {},
|
| 265 |
+
"source": ["## Step 6 \u2014 Load model (Qwen2.5-3B-Instruct, 4-bit)"]
|
| 266 |
+
},
|
| 267 |
+
{
|
| 268 |
+
"cell_type": "code",
|
| 269 |
+
"execution_count": null,
|
| 270 |
+
"id": "b6",
|
| 271 |
+
"metadata": {},
|
| 272 |
+
"outputs": [],
|
| 273 |
+
"source": [
|
| 274 |
+
"import os, sys\n",
|
| 275 |
+
"os.chdir('/content/data-centric-env')\n",
|
| 276 |
+
"sys.path.insert(0, '/content/data-centric-env')\n",
|
| 277 |
+
"\n",
|
| 278 |
+
"from unsloth import FastLanguageModel\n",
|
| 279 |
+
"\n",
|
| 280 |
+
"MODEL_NAME = 'Qwen/Qwen2.5-3B-Instruct'\n",
|
| 281 |
+
"MAX_SEQ_LENGTH = 1024\n",
|
| 282 |
+
"\n",
|
| 283 |
+
"model, tokenizer = FastLanguageModel.from_pretrained(\n",
|
| 284 |
+
" model_name=MODEL_NAME,\n",
|
| 285 |
+
" max_seq_length=MAX_SEQ_LENGTH,\n",
|
| 286 |
+
" load_in_4bit=True,\n",
|
| 287 |
+
" dtype=None,\n",
|
| 288 |
+
")\n",
|
| 289 |
+
"\n",
|
| 290 |
+
"model = FastLanguageModel.get_peft_model(\n",
|
| 291 |
+
" model,\n",
|
| 292 |
+
" r=16,\n",
|
| 293 |
+
" target_modules=['q_proj', 'v_proj', 'k_proj', 'o_proj'],\n",
|
| 294 |
+
" lora_alpha=32,\n",
|
| 295 |
+
" lora_dropout=0,\n",
|
| 296 |
+
" bias='none',\n",
|
| 297 |
+
" use_gradient_checkpointing='unsloth',\n",
|
| 298 |
+
" random_state=42,\n",
|
| 299 |
+
")\n",
|
| 300 |
+
"\n",
|
| 301 |
+
"import torch\n",
|
| 302 |
+
"print(f'Model loaded: {MODEL_NAME}')\n",
|
| 303 |
+
"print(f'VRAM: {torch.cuda.memory_allocated()/1e9:.1f} GB')"
|
| 304 |
+
]
|
| 305 |
+
},
|
| 306 |
+
{
|
| 307 |
+
"cell_type": "markdown",
|
| 308 |
+
"id": "a7",
|
| 309 |
+
"metadata": {},
|
| 310 |
+
"source": ["## Step 7 \u2014 Phase 1: SFT Warmup (~30 min)"]
|
| 311 |
+
},
|
| 312 |
+
{
|
| 313 |
+
"cell_type": "code",
|
| 314 |
+
"execution_count": null,
|
| 315 |
+
"id": "b7",
|
| 316 |
+
"metadata": {},
|
| 317 |
+
"outputs": [],
|
| 318 |
+
"source": [
|
| 319 |
+
"import os, sys\n",
|
| 320 |
+
"os.chdir('/content/data-centric-env')\n",
|
| 321 |
+
"sys.path.insert(0, '/content/data-centric-env')\n",
|
| 322 |
+
"\n",
|
| 323 |
+
"from train_data_centric import run_sft_warmup\n",
|
| 324 |
+
"model = run_sft_warmup(model, tokenizer)\n",
|
| 325 |
+
"print('SFT warmup complete')"
|
| 326 |
+
]
|
| 327 |
+
},
|
| 328 |
+
{
|
| 329 |
+
"cell_type": "markdown",
|
| 330 |
+
"id": "a8",
|
| 331 |
+
"metadata": {},
|
| 332 |
+
"source": [
|
| 333 |
+
"## Step 8 \u2014 Phase 2: GRPO Training (~3-5 hrs on T4)\n\n",
|
| 334 |
+
"Agent learns **when** to use each command via reinforcement learning.\n",
|
| 335 |
+
"Checkpoints save every 50 steps. If Colab disconnects, re-run all cells \u2014\n",
|
| 336 |
+
"it auto-resumes from the latest checkpoint.\n\n",
|
| 337 |
+
"**What the agent will learn:**\n",
|
| 338 |
+
"- Start with `query_analyst` to get a prioritised plan\n",
|
| 339 |
+
"- Use the Agreement signal to detect overfitting\n",
|
| 340 |
+
"- Focus cleaning on high-importance features\n",
|
| 341 |
+
"- Undo bad applies when drift is HIGH"
|
| 342 |
+
]
|
| 343 |
+
},
|
| 344 |
+
{
|
| 345 |
+
"cell_type": "code",
|
| 346 |
+
"execution_count": null,
|
| 347 |
+
"id": "b8",
|
| 348 |
+
"metadata": {},
|
| 349 |
+
"outputs": [],
|
| 350 |
+
"source": [
|
| 351 |
+
"import os, sys, glob\n",
|
| 352 |
+
"os.chdir('/content/data-centric-env')\n",
|
| 353 |
+
"sys.path.insert(0, '/content/data-centric-env')\n",
|
| 354 |
+
"\n",
|
| 355 |
+
"from train_data_centric import run_grpo_training\n",
|
| 356 |
+
"\n",
|
| 357 |
+
"# Auto-detect checkpoint to resume from\n",
|
| 358 |
+
"ckpt_dir = './data-centric-checkpoints'\n",
|
| 359 |
+
"resume_from = None\n",
|
| 360 |
+
"if os.path.exists(ckpt_dir):\n",
|
| 361 |
+
" checkpoints = sorted(glob.glob(f'{ckpt_dir}/checkpoint-*'))\n",
|
| 362 |
+
" if checkpoints:\n",
|
| 363 |
+
" resume_from = checkpoints[-1]\n",
|
| 364 |
+
" print(f'Resuming from: {resume_from}')\n",
|
| 365 |
+
" else:\n",
|
| 366 |
+
" print('No checkpoint found - starting fresh')\n",
|
| 367 |
+
"\n",
|
| 368 |
+
"model = run_grpo_training(model, tokenizer, resume_from_checkpoint=resume_from)\n",
|
| 369 |
+
"print('GRPO training complete')"
|
| 370 |
+
]
|
| 371 |
+
},
|
| 372 |
+
{
|
| 373 |
+
"cell_type": "markdown",
|
| 374 |
+
"id": "a9",
|
| 375 |
+
"metadata": {},
|
| 376 |
+
"source": ["## Step 9 \u2014 Save trained model"]
|
| 377 |
+
},
|
| 378 |
+
{
|
| 379 |
+
"cell_type": "code",
|
| 380 |
+
"execution_count": null,
|
| 381 |
+
"id": "b9",
|
| 382 |
+
"metadata": {},
|
| 383 |
+
"outputs": [],
|
| 384 |
+
"source": [
|
| 385 |
+
"import os, sys\n",
|
| 386 |
+
"os.chdir('/content/data-centric-env')\n",
|
| 387 |
+
"sys.path.insert(0, '/content/data-centric-env')\n",
|
| 388 |
+
"\n",
|
| 389 |
+
"from train_data_centric import save_model\n",
|
| 390 |
+
"save_model(model, tokenizer)\n",
|
| 391 |
+
"print('Model saved to ./data-centric-adapter/')"
|
| 392 |
+
]
|
| 393 |
+
},
|
| 394 |
+
{
|
| 395 |
+
"cell_type": "markdown",
|
| 396 |
+
"id": "a10",
|
| 397 |
+
"metadata": {},
|
| 398 |
+
"source": ["## Step 10 \u2014 Plot reward curves"]
|
| 399 |
+
},
|
| 400 |
+
{
|
| 401 |
+
"cell_type": "code",
|
| 402 |
+
"execution_count": null,
|
| 403 |
+
"id": "b10",
|
| 404 |
+
"metadata": {},
|
| 405 |
+
"outputs": [],
|
| 406 |
+
"source": [
|
| 407 |
+
"import os\n",
|
| 408 |
+
"os.chdir('/content/data-centric-env')\n",
|
| 409 |
+
"\n",
|
| 410 |
+
"if os.path.exists('logs/training.jsonl'):\n",
|
| 411 |
+
" lines = sum(1 for _ in open('logs/training.jsonl'))\n",
|
| 412 |
+
" print(f'Training log: {lines} episodes recorded')\n",
|
| 413 |
+
" !python plot_rewards.py --log logs/training.jsonl --out plots/\n",
|
| 414 |
+
" from IPython.display import Image, display\n",
|
| 415 |
+
" for f in ['reward_curve.png', 'success_rate.png', 'accuracy_gain.png', 'curriculum.png']:\n",
|
| 416 |
+
" path = f'plots/{f}'\n",
|
| 417 |
+
" if os.path.exists(path):\n",
|
| 418 |
+
" print(f'\\n--- {f} ---')\n",
|
| 419 |
+
" display(Image(path))\n",
|
| 420 |
+
"else:\n",
|
| 421 |
+
" print('No training log found yet - run Step 8 first')"
|
| 422 |
+
]
|
| 423 |
+
},
|
| 424 |
+
{
|
| 425 |
+
"cell_type": "markdown",
|
| 426 |
+
"id": "a11",
|
| 427 |
+
"metadata": {},
|
| 428 |
+
"source": ["## Step 11 \u2014 Evaluate trained agent vs random vs heuristic"]
|
| 429 |
+
},
|
| 430 |
+
{
|
| 431 |
+
"cell_type": "code",
|
| 432 |
+
"execution_count": null,
|
| 433 |
+
"id": "b11",
|
| 434 |
+
"metadata": {},
|
| 435 |
+
"outputs": [],
|
| 436 |
+
"source": [
|
| 437 |
+
"import os, json\n",
|
| 438 |
+
"os.chdir('/content/data-centric-env')\n",
|
| 439 |
+
"\n",
|
| 440 |
+
"!python eval_data_centric.py\n",
|
| 441 |
+
"\n",
|
| 442 |
+
"if os.path.exists('eval_results.json'):\n",
|
| 443 |
+
" with open('eval_results.json') as f:\n",
|
| 444 |
+
" results = json.load(f)\n",
|
| 445 |
+
" print('\\n=== Eval Results ===')\n",
|
| 446 |
+
" for k, v in results.items():\n",
|
| 447 |
+
" print(f' {k}: {v}')"
|
| 448 |
+
]
|
| 449 |
+
},
|
| 450 |
+
{
|
| 451 |
+
"cell_type": "markdown",
|
| 452 |
+
"id": "a12",
|
| 453 |
+
"metadata": {},
|
| 454 |
+
"source": [
|
| 455 |
+
"## Step 12 \u2014 Push results to GitHub\n\n",
|
| 456 |
+
"Run this **only after training + eval are done**."
|
| 457 |
+
]
|
| 458 |
+
},
|
| 459 |
+
{
|
| 460 |
+
"cell_type": "code",
|
| 461 |
+
"execution_count": null,
|
| 462 |
+
"id": "b12",
|
| 463 |
+
"metadata": {},
|
| 464 |
+
"outputs": [],
|
| 465 |
+
"source": [
|
| 466 |
+
"import os\n",
|
| 467 |
+
"os.chdir('/content/data-centric-env')\n",
|
| 468 |
+
"\n",
|
| 469 |
+
"from getpass import getpass\n",
|
| 470 |
+
"token = getpass('GitHub token (repo write access): ')\n",
|
| 471 |
+
"repo_url = f'https://{token}@github.com/CelestialWorthyOfHeavenAndEarth/data-centric-env.git'\n",
|
| 472 |
+
"\n",
|
| 473 |
+
"!git config user.email 'training@colab.run'\n",
|
| 474 |
+
"!git config user.name 'Colab Training'\n",
|
| 475 |
+
"!git remote set-url origin {repo_url}\n",
|
| 476 |
+
"!git add plots/ logs/ eval_results.json 2>/dev/null || true\n",
|
| 477 |
+
"!git commit -m 'Add training results: reward curves + eval'\n",
|
| 478 |
+
"!git push origin main\n",
|
| 479 |
+
"print('Results pushed to GitHub')"
|
| 480 |
+
]
|
| 481 |
+
},
|
| 482 |
+
{
|
| 483 |
+
"cell_type": "markdown",
|
| 484 |
+
"id": "a13",
|
| 485 |
+
"metadata": {},
|
| 486 |
+
"source": [
|
| 487 |
+
"---\n",
|
| 488 |
+
"## Done!\n\n",
|
| 489 |
+
"| Output | Location |\n",
|
| 490 |
+
"|--------|----------|\n",
|
| 491 |
+
"| Trained adapter | `./data-centric-adapter/` |\n",
|
| 492 |
+
"| Training log | `logs/training.jsonl` |\n",
|
| 493 |
+
"| Reward curves | `plots/*.png` |\n",
|
| 494 |
+
"| Eval results | `eval_results.json` |"
|
| 495 |
+
]
|
| 496 |
+
}
|
| 497 |
+
]
|
| 498 |
+
}
|
train_data_centric.py
ADDED
|
@@ -0,0 +1,689 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# pip install trl unsloth transformers torch requests
|
| 2 |
+
# pip install matplotlib openenv-core scikit-learn pandas numpy datasets
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
import json
|
| 6 |
+
import random
|
| 7 |
+
import time
|
| 8 |
+
import signal
|
| 9 |
+
import subprocess
|
| 10 |
+
import requests
|
| 11 |
+
import torch
|
| 12 |
+
from collections import deque
|
| 13 |
+
from statistics import mean
|
| 14 |
+
from datasets import Dataset
|
| 15 |
+
from unsloth import FastLanguageModel
|
| 16 |
+
from trl import SFTTrainer, SFTConfig, GRPOConfig, GRPOTrainer
|
| 17 |
+
|
| 18 |
+
# WebSocket client for stateful episode rollouts
|
| 19 |
+
sys_path_root = os.path.dirname(os.path.abspath(__file__))
|
| 20 |
+
import sys
|
| 21 |
+
if sys_path_root not in sys.path:
|
| 22 |
+
sys.path.insert(0, sys_path_root)
|
| 23 |
+
from client import DataCentricEnv
|
| 24 |
+
from models import DataCentricAction
|
| 25 |
+
|
| 26 |
+
# ════════════════════════════════════════════════════════
|
| 27 |
+
# CONSTANTS
|
| 28 |
+
# ════════════════════════════════════════════════════════
|
| 29 |
+
|
| 30 |
+
# ENV_URL: set this to your HF Space URL when running as an HF Job
|
| 31 |
+
# e.g. export ENV_URL="https://aswini-kumar-data-centric-env.hf.space"
|
| 32 |
+
BASE_URL = os.environ.get("ENV_URL", "http://localhost:8000")
|
| 33 |
+
MODEL_NAME = "Qwen/Qwen2.5-3B-Instruct"
|
| 34 |
+
MAX_SEQ_LENGTH = 1024
|
| 35 |
+
LOAD_IN_4BIT = True
|
| 36 |
+
|
| 37 |
+
VALID_COMMANDS = [
|
| 38 |
+
"inspect_dataset", "inspect_model", "query_analyst",
|
| 39 |
+
"query_cleaner", "query_augmenter", "query_balancer", "query_validator",
|
| 40 |
+
"apply", "reject", "undo", "validate", "submit",
|
| 41 |
+
]
|
| 42 |
+
|
| 43 |
+
SYSTEM_PROMPT = """You are a Data-Centric AI agent. Your job is to improve a \
|
| 44 |
+
Machine learning dataset so a fixed classifier achieves higher accuracy.
|
| 45 |
+
|
| 46 |
+
STRATEGY — use this order:
|
| 47 |
+
1. query_analyst to get a prioritised action plan (costs 1 budget, worth it)
|
| 48 |
+
2. inspect_dataset to understand the data
|
| 49 |
+
3. query the recommended specialist (query_cleaner, query_augmenter, query_balancer)
|
| 50 |
+
4. apply the best recommendation by number (e.g. apply 1)
|
| 51 |
+
5. validate to check if accuracy improved
|
| 52 |
+
6. repeat until you hit the target or run low on budget
|
| 53 |
+
7. submit to finalize
|
| 54 |
+
|
| 55 |
+
IMPORTANT RULES:
|
| 56 |
+
- Start with query_analyst — it tells you the biggest problem to fix first.
|
| 57 |
+
- Always query a specialist before applying. Never apply without querying first.
|
| 58 |
+
- Check the Agreement signal after validate: DISAGREE means possible overfitting.
|
| 59 |
+
- Validate after every 2-3 applies to track progress.
|
| 60 |
+
- Do not spam validate — it costs budget after 3 uses.
|
| 61 |
+
- query_validator costs 2 budget — use only when suspicious of data quality.
|
| 62 |
+
- submit when accuracy >= target or budget < 5.
|
| 63 |
+
|
| 64 |
+
Reply with exactly ONE command per message. No explanation. Just the command."""
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def build_user_prompt(obs: dict) -> str:
|
| 68 |
+
improvement_needed = obs.get("target_accuracy", 0) - obs.get("current_accuracy", 0)
|
| 69 |
+
return (
|
| 70 |
+
f"Current situation:\n"
|
| 71 |
+
f"Accuracy: {obs.get('current_accuracy', 0):.1%} → "
|
| 72 |
+
f"Target: {obs.get('target_accuracy', 0):.1%}\n"
|
| 73 |
+
f"Still need: {max(0, improvement_needed):.1%} improvement\n"
|
| 74 |
+
f"Quality score: {obs.get('estimated_quality', 0):.2f}/1.00\n"
|
| 75 |
+
f"Rows preserved: {obs.get('rows_preserved_pct', 1.0):.1%}\n"
|
| 76 |
+
f"Budget remaining: {obs.get('budget_remaining', 0)} steps\n"
|
| 77 |
+
f"Free validates left: {obs.get('validate_calls_remaining', 0)}\n"
|
| 78 |
+
f"Active query session: {obs.get('active_session', 'none')}\n\n"
|
| 79 |
+
f"Last result:\n{str(obs.get('response', ''))[:400]}\n\n"
|
| 80 |
+
f"What is your next action? (one command only)"
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# ════════════════════════════════════════════════════════
|
| 85 |
+
# SERVER MANAGEMENT
|
| 86 |
+
# ════════════════════════════════════════════════════════
|
| 87 |
+
|
| 88 |
+
def start_server() -> subprocess.Popen:
|
| 89 |
+
"""Start the environment server as a subprocess."""
|
| 90 |
+
proc = subprocess.Popen(
|
| 91 |
+
["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000"],
|
| 92 |
+
stdout=subprocess.DEVNULL,
|
| 93 |
+
stderr=subprocess.DEVNULL,
|
| 94 |
+
)
|
| 95 |
+
# Poll until ready (max 30 seconds)
|
| 96 |
+
for i in range(30):
|
| 97 |
+
try:
|
| 98 |
+
r = requests.get(f"{BASE_URL}/health", timeout=1)
|
| 99 |
+
if r.status_code == 200:
|
| 100 |
+
print(f"Server ready after {i + 1}s")
|
| 101 |
+
return proc
|
| 102 |
+
except Exception:
|
| 103 |
+
pass
|
| 104 |
+
time.sleep(1)
|
| 105 |
+
proc.terminate()
|
| 106 |
+
raise RuntimeError("Environment server failed to start in 30 seconds")
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def stop_server(proc: subprocess.Popen):
|
| 110 |
+
proc.terminate() # cross-platform (SIGTERM on Linux, TerminateProcess on Windows)
|
| 111 |
+
proc.wait()
|
| 112 |
+
print("Server stopped.")
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# ════════════════════════════════════════════════════════
|
| 116 |
+
# MODEL SETUP
|
| 117 |
+
# ════════��═══════════════════════════════════════════════
|
| 118 |
+
|
| 119 |
+
def load_model():
|
| 120 |
+
model, tokenizer = FastLanguageModel.from_pretrained(
|
| 121 |
+
model_name=MODEL_NAME,
|
| 122 |
+
max_seq_length=MAX_SEQ_LENGTH,
|
| 123 |
+
load_in_4bit=LOAD_IN_4BIT,
|
| 124 |
+
dtype=None,
|
| 125 |
+
)
|
| 126 |
+
model = FastLanguageModel.get_peft_model(
|
| 127 |
+
model,
|
| 128 |
+
r=16,
|
| 129 |
+
target_modules=["q_proj", "v_proj", "k_proj", "o_proj"],
|
| 130 |
+
lora_alpha=32,
|
| 131 |
+
lora_dropout=0,
|
| 132 |
+
bias="none",
|
| 133 |
+
use_gradient_checkpointing="unsloth",
|
| 134 |
+
random_state=42,
|
| 135 |
+
)
|
| 136 |
+
return model, tokenizer
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# ════════════════════════════════════════════════════════
|
| 140 |
+
# PHASE 1 — SFT WARMUP
|
| 141 |
+
# ════════════════════════════════════════════════════════
|
| 142 |
+
|
| 143 |
+
def run_sft_warmup(model, tokenizer):
|
| 144 |
+
"""
|
| 145 |
+
1 epoch of SFT on heuristic trajectories.
|
| 146 |
+
Teaches model valid command format before GRPO starts.
|
| 147 |
+
Without this, model outputs gibberish and gets zero reward.
|
| 148 |
+
"""
|
| 149 |
+
print("\n=== PHASE 1: SFT WARMUP ===")
|
| 150 |
+
|
| 151 |
+
if os.path.exists("./sft-checkpoint"):
|
| 152 |
+
model.load_adapter("./sft-checkpoint")
|
| 153 |
+
print("Loaded existing SFT checkpoint — skipping warmup.")
|
| 154 |
+
return model
|
| 155 |
+
|
| 156 |
+
if not os.path.exists("sft_data.jsonl"):
|
| 157 |
+
print("sft_data.jsonl not found. Run sft_generator.py first.")
|
| 158 |
+
raise FileNotFoundError("sft_data.jsonl missing")
|
| 159 |
+
|
| 160 |
+
raw = [json.loads(l) for l in open("sft_data.jsonl", encoding="utf-8")]
|
| 161 |
+
print(f"Loaded {len(raw)} SFT examples")
|
| 162 |
+
|
| 163 |
+
def format_example(ex):
|
| 164 |
+
messages = [
|
| 165 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 166 |
+
{"role": "user", "content": ex["prompt"]},
|
| 167 |
+
{"role": "assistant", "content": ex["response"]},
|
| 168 |
+
]
|
| 169 |
+
return {
|
| 170 |
+
"text": tokenizer.apply_chat_template(
|
| 171 |
+
messages, tokenize=False, add_generation_prompt=False
|
| 172 |
+
)
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
sft_dataset = Dataset.from_list([format_example(ex) for ex in raw])
|
| 176 |
+
|
| 177 |
+
sft_trainer = SFTTrainer(
|
| 178 |
+
model=model,
|
| 179 |
+
tokenizer=tokenizer,
|
| 180 |
+
train_dataset=sft_dataset,
|
| 181 |
+
args=SFTConfig(
|
| 182 |
+
output_dir="./sft-checkpoint",
|
| 183 |
+
num_train_epochs=1,
|
| 184 |
+
per_device_train_batch_size=4,
|
| 185 |
+
gradient_accumulation_steps=4,
|
| 186 |
+
learning_rate=2e-5,
|
| 187 |
+
warmup_steps=10,
|
| 188 |
+
logging_steps=5,
|
| 189 |
+
save_strategy="no",
|
| 190 |
+
report_to="none",
|
| 191 |
+
max_seq_length=MAX_SEQ_LENGTH,
|
| 192 |
+
),
|
| 193 |
+
)
|
| 194 |
+
sft_trainer.train()
|
| 195 |
+
print("SFT warmup complete.\n")
|
| 196 |
+
return model
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
# ════════════════════════════════════════════════════════
|
| 200 |
+
# CURRICULUM SCHEDULER
|
| 201 |
+
# ════════════════════════════════════════════════════════
|
| 202 |
+
|
| 203 |
+
class CurriculumScheduler:
|
| 204 |
+
"""
|
| 205 |
+
Advances curriculum level when the agent reliably solves the current task.
|
| 206 |
+
|
| 207 |
+
Advancement criterion: >= threshold success rate over a rolling window of episodes.
|
| 208 |
+
Uses a smoothed window to avoid premature advancement on lucky streaks.
|
| 209 |
+
|
| 210 |
+
Levels: 0=tutorial, 1=easy, 2=medium, 3=hard
|
| 211 |
+
|
| 212 |
+
Design rationale:
|
| 213 |
+
- Step-count based scheduling causes premature advancement (catastrophic forgetting)
|
| 214 |
+
or stalling (wasted compute) because it ignores actual agent performance.
|
| 215 |
+
- Success-rate based scheduling ensures the agent genuinely masters a level
|
| 216 |
+
before seeing harder tasks, matching curriculum RL best practices.
|
| 217 |
+
- Window resets after each advancement so the agent must prove itself again.
|
| 218 |
+
"""
|
| 219 |
+
|
| 220 |
+
TASKS = ["task_0_tutorial", "task_1_easy", "task_2_medium", "task_3_hard"]
|
| 221 |
+
LEVEL_LABELS = ["tutorial", "easy", "medium", "hard"]
|
| 222 |
+
|
| 223 |
+
def __init__(self, window: int = 30, threshold: float = 0.60):
|
| 224 |
+
"""
|
| 225 |
+
Args:
|
| 226 |
+
window: Number of episodes to evaluate before considering advancement.
|
| 227 |
+
30 gives a stable estimate without waiting too long.
|
| 228 |
+
threshold: Required success rate (0.0–1.0) to advance to next level.
|
| 229 |
+
0.60 = agent must solve 60% of episodes to advance.
|
| 230 |
+
Lower than classic 0.75 because 3B models learn slower.
|
| 231 |
+
"""
|
| 232 |
+
self.current_level = 0
|
| 233 |
+
self.window = window
|
| 234 |
+
self.threshold = threshold
|
| 235 |
+
self.recent_successes: deque = deque(maxlen=window)
|
| 236 |
+
self.global_step = 0 # total episodes recorded (for logging)
|
| 237 |
+
self.level_history: list = [] # (episode, level) pairs for plotting
|
| 238 |
+
|
| 239 |
+
def record_episode(self, reached_target: bool, accuracy_gain: float = 0.0):
|
| 240 |
+
"""Call after every episode completes."""
|
| 241 |
+
self.recent_successes.append(float(reached_target))
|
| 242 |
+
self.global_step += 1
|
| 243 |
+
if self.should_advance():
|
| 244 |
+
self.advance()
|
| 245 |
+
|
| 246 |
+
def get_task(self) -> str:
|
| 247 |
+
"""Return the current training task name."""
|
| 248 |
+
return self.TASKS[self.current_level]
|
| 249 |
+
|
| 250 |
+
def current_success_rate(self) -> float:
|
| 251 |
+
if not self.recent_successes:
|
| 252 |
+
return 0.0
|
| 253 |
+
return sum(self.recent_successes) / len(self.recent_successes)
|
| 254 |
+
|
| 255 |
+
def should_advance(self) -> bool:
|
| 256 |
+
"""Only advance if we have enough data and consistently exceed threshold."""
|
| 257 |
+
return (
|
| 258 |
+
len(self.recent_successes) >= self.window
|
| 259 |
+
and self.current_success_rate() >= self.threshold
|
| 260 |
+
and self.current_level < len(self.TASKS) - 1
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
def advance(self):
|
| 264 |
+
if self.current_level < len(self.TASKS) - 1:
|
| 265 |
+
print(
|
| 266 |
+
f"\n[Curriculum] ▶ Level {self.current_level} ({self.TASKS[self.current_level]}) "
|
| 267 |
+
f"→ Level {self.current_level + 1} ({self.TASKS[self.current_level + 1]})\n"
|
| 268 |
+
f" Success rate over last {self.window} episodes: "
|
| 269 |
+
f"{self.current_success_rate():.1%} (threshold: {self.threshold:.0%})\n"
|
| 270 |
+
f" Total episodes: {self.global_step}"
|
| 271 |
+
)
|
| 272 |
+
self.level_history.append((self.global_step, self.current_level))
|
| 273 |
+
self.current_level += 1
|
| 274 |
+
self.recent_successes.clear() # reset window after advancing
|
| 275 |
+
|
| 276 |
+
def stage_label(self) -> str:
|
| 277 |
+
return self.LEVEL_LABELS[self.current_level]
|
| 278 |
+
|
| 279 |
+
# Backward-compat: record_improvement still works for old callers
|
| 280 |
+
def record_improvement(self, improvement: float):
|
| 281 |
+
self.record_episode(reached_target=(improvement > 0.05))
|
| 282 |
+
self.global_step = self.global_step # already incremented above
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
# ════════════════════════════════════════════════════════
|
| 288 |
+
# REWARD COMPUTATION
|
| 289 |
+
# ════════════════════════════════════════════════════════
|
| 290 |
+
|
| 291 |
+
def compute_rewards(
|
| 292 |
+
obs_before: dict,
|
| 293 |
+
obs_after: dict,
|
| 294 |
+
response_text: str,
|
| 295 |
+
action_history: list,
|
| 296 |
+
) -> dict:
|
| 297 |
+
"""
|
| 298 |
+
4 completely independent reward components.
|
| 299 |
+
Each measures a different aspect of agent behavior.
|
| 300 |
+
No single component can be maximized without solving the real task.
|
| 301 |
+
"""
|
| 302 |
+
# Component 1: Environment accuracy reward (from environment)
|
| 303 |
+
env_reward = obs_after.get("reward", 0.0)
|
| 304 |
+
|
| 305 |
+
# Component 2: Format reward — did model output a valid command?
|
| 306 |
+
is_valid = any(
|
| 307 |
+
response_text.strip().startswith(cmd) for cmd in VALID_COMMANDS
|
| 308 |
+
)
|
| 309 |
+
format_reward = 0.10 if is_valid else -0.10
|
| 310 |
+
|
| 311 |
+
# Component 3: Strategy reward — did model follow smart workflow?
|
| 312 |
+
strategy_reward = 0.0
|
| 313 |
+
if len(action_history) >= 1:
|
| 314 |
+
prev = action_history[-1]
|
| 315 |
+
curr = response_text.strip()
|
| 316 |
+
|
| 317 |
+
if prev.startswith("query_") and curr.startswith("apply"):
|
| 318 |
+
strategy_reward = 0.05 # query then apply — correct pattern
|
| 319 |
+
elif curr.startswith("apply") and not any(
|
| 320 |
+
a.startswith("query_") for a in action_history[-3:]
|
| 321 |
+
):
|
| 322 |
+
strategy_reward = -0.04 # apply without recent query — bad pattern
|
| 323 |
+
elif prev.startswith("apply") and curr == "validate":
|
| 324 |
+
strategy_reward = 0.03 # validate after apply — good pattern
|
| 325 |
+
elif curr == "submit" and not any(
|
| 326 |
+
a == "validate" for a in action_history
|
| 327 |
+
):
|
| 328 |
+
strategy_reward = -0.10 # submit without ever validating — bad
|
| 329 |
+
|
| 330 |
+
# Component 4: Preservation reward — did model lose good data?
|
| 331 |
+
rows_pct = obs_after.get("rows_preserved_pct", 1.0)
|
| 332 |
+
if rows_pct >= 0.90: preservation_reward = 0.05
|
| 333 |
+
elif rows_pct >= 0.80: preservation_reward = 0.02
|
| 334 |
+
elif rows_pct >= 0.70: preservation_reward = 0.00
|
| 335 |
+
elif rows_pct >= 0.50: preservation_reward = -0.10
|
| 336 |
+
else: preservation_reward = -0.40
|
| 337 |
+
|
| 338 |
+
total = env_reward + format_reward + strategy_reward + preservation_reward
|
| 339 |
+
|
| 340 |
+
return {
|
| 341 |
+
"total": total,
|
| 342 |
+
"env": env_reward,
|
| 343 |
+
"format": format_reward,
|
| 344 |
+
"strategy": strategy_reward,
|
| 345 |
+
"preservation": preservation_reward,
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
# ════════════════════════════════════════════════════════
|
| 350 |
+
# EPISODE ROLLOUT
|
| 351 |
+
# ════════════════════════════════════════════════════════
|
| 352 |
+
|
| 353 |
+
def obs_to_dict(obs_obj) -> dict:
|
| 354 |
+
"""Convert DataCentricObservation to dict for compatibility with reward logic."""
|
| 355 |
+
if isinstance(obs_obj, dict):
|
| 356 |
+
return obs_obj
|
| 357 |
+
return {
|
| 358 |
+
"response": obs_obj.response,
|
| 359 |
+
"current_accuracy": obs_obj.current_accuracy,
|
| 360 |
+
"baseline_accuracy": obs_obj.baseline_accuracy,
|
| 361 |
+
"target_accuracy": obs_obj.target_accuracy,
|
| 362 |
+
"estimated_quality": obs_obj.estimated_quality,
|
| 363 |
+
"dataset_shape": obs_obj.dataset_shape,
|
| 364 |
+
"rows_preserved_pct": obs_obj.rows_preserved_pct,
|
| 365 |
+
"budget_remaining": obs_obj.budget_remaining,
|
| 366 |
+
"step_number": obs_obj.step_number,
|
| 367 |
+
"max_steps": obs_obj.max_steps,
|
| 368 |
+
"active_session": obs_obj.active_session,
|
| 369 |
+
"validate_calls_remaining":obs_obj.validate_calls_remaining,
|
| 370 |
+
"done": obs_obj.done,
|
| 371 |
+
"reward": obs_obj.reward,
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
def run_episode(
|
| 376 |
+
model, tokenizer, task: str, seed: int
|
| 377 |
+
) -> tuple:
|
| 378 |
+
"""
|
| 379 |
+
Run one complete episode using the WebSocket client (stateful session).
|
| 380 |
+
Each reset+step sequence maintains the same env instance on the server.
|
| 381 |
+
Returns: (prompts, responses, rewards) for GRPO training.
|
| 382 |
+
"""
|
| 383 |
+
prompts, responses, rewards = [], [], []
|
| 384 |
+
action_history = []
|
| 385 |
+
|
| 386 |
+
try:
|
| 387 |
+
with DataCentricEnv(base_url=BASE_URL).sync() as env:
|
| 388 |
+
reset_result = env.reset(task=task, seed=seed)
|
| 389 |
+
obs = obs_to_dict(reset_result.observation)
|
| 390 |
+
|
| 391 |
+
while not obs.get("done", False):
|
| 392 |
+
# Build chat prompt
|
| 393 |
+
messages = [
|
| 394 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 395 |
+
{"role": "user", "content": build_user_prompt(obs)},
|
| 396 |
+
]
|
| 397 |
+
input_ids = tokenizer.apply_chat_template(
|
| 398 |
+
messages,
|
| 399 |
+
return_tensors="pt",
|
| 400 |
+
max_length=MAX_SEQ_LENGTH - 60,
|
| 401 |
+
truncation=True,
|
| 402 |
+
add_generation_prompt=True,
|
| 403 |
+
).to(model.device)
|
| 404 |
+
|
| 405 |
+
# Generate — commands are short, 50 tokens max
|
| 406 |
+
with torch.no_grad():
|
| 407 |
+
output_ids = model.generate(
|
| 408 |
+
input_ids,
|
| 409 |
+
max_new_tokens=50,
|
| 410 |
+
temperature=0.8,
|
| 411 |
+
do_sample=True,
|
| 412 |
+
pad_token_id=tokenizer.eos_token_id,
|
| 413 |
+
)
|
| 414 |
+
|
| 415 |
+
response_text = tokenizer.decode(
|
| 416 |
+
output_ids[0][input_ids.shape[1]:],
|
| 417 |
+
skip_special_tokens=True,
|
| 418 |
+
).strip().split("\n")[0].strip()[:200]
|
| 419 |
+
|
| 420 |
+
obs_before = obs
|
| 421 |
+
try:
|
| 422 |
+
step_result = env.step(DataCentricAction(message=response_text))
|
| 423 |
+
obs = obs_to_dict(step_result.observation)
|
| 424 |
+
except Exception as e:
|
| 425 |
+
obs = {**obs, "done": True, "reward": -0.05,
|
| 426 |
+
"response": f"Step error: {e}"}
|
| 427 |
+
|
| 428 |
+
reward_dict = compute_rewards(
|
| 429 |
+
obs_before, obs, response_text, action_history
|
| 430 |
+
)
|
| 431 |
+
prompts.append(build_user_prompt(obs_before))
|
| 432 |
+
responses.append(response_text)
|
| 433 |
+
rewards.append(reward_dict)
|
| 434 |
+
action_history.append(response_text)
|
| 435 |
+
|
| 436 |
+
except Exception as e:
|
| 437 |
+
print(f"Episode error (task={task}, seed={seed}): {e}")
|
| 438 |
+
return [], [], []
|
| 439 |
+
|
| 440 |
+
return prompts, responses, rewards
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
# ════════════════════════════════════════════════════════
|
| 444 |
+
# LOGGING
|
| 445 |
+
# ════════════════════════════════════════════════════════
|
| 446 |
+
|
| 447 |
+
training_log = []
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
def log_training_step(
|
| 451 |
+
step: int, all_episodes: list, scheduler: CurriculumScheduler
|
| 452 |
+
):
|
| 453 |
+
"""Log metrics and sample generations every 10 steps."""
|
| 454 |
+
all_final_rewards = []
|
| 455 |
+
all_reward_components: dict = {
|
| 456 |
+
"env": [], "format": [], "strategy": [], "preservation": []
|
| 457 |
+
}
|
| 458 |
+
format_hits = 0
|
| 459 |
+
strategy_hits = 0
|
| 460 |
+
total_actions = 0
|
| 461 |
+
|
| 462 |
+
for prompts, responses, rewards in all_episodes:
|
| 463 |
+
if not rewards:
|
| 464 |
+
continue
|
| 465 |
+
all_final_rewards.append(rewards[-1]["total"])
|
| 466 |
+
for r in rewards:
|
| 467 |
+
for k in all_reward_components:
|
| 468 |
+
all_reward_components[k].append(r[k])
|
| 469 |
+
if r["format"] > 0: format_hits += 1
|
| 470 |
+
if r["strategy"] > 0: strategy_hits += 1
|
| 471 |
+
total_actions += 1
|
| 472 |
+
|
| 473 |
+
if not all_final_rewards:
|
| 474 |
+
return
|
| 475 |
+
|
| 476 |
+
entry = {
|
| 477 |
+
"step": step,
|
| 478 |
+
"stage": scheduler.stage_label(),
|
| 479 |
+
"task": scheduler.get_task(),
|
| 480 |
+
"mean_total_reward": mean(all_final_rewards),
|
| 481 |
+
"mean_env_reward": mean(all_reward_components["env"]),
|
| 482 |
+
"mean_format_reward": mean(all_reward_components["format"]),
|
| 483 |
+
"mean_strategy_reward": mean(all_reward_components["strategy"]),
|
| 484 |
+
"mean_preservation_reward": mean(all_reward_components["preservation"]),
|
| 485 |
+
"format_rate": format_hits / max(total_actions, 1),
|
| 486 |
+
"strategy_rate": strategy_hits / max(total_actions, 1),
|
| 487 |
+
}
|
| 488 |
+
training_log.append(entry)
|
| 489 |
+
json.dump(training_log, open("training_log.json", "w"), indent=2)
|
| 490 |
+
|
| 491 |
+
print(
|
| 492 |
+
f"Step {step:4d} | Stage: {entry['stage']:8s} | "
|
| 493 |
+
f"Reward: {entry['mean_total_reward']:+.3f} | "
|
| 494 |
+
f"Format: {entry['format_rate']:.0%} | "
|
| 495 |
+
f"Strategy: {entry['strategy_rate']:.0%}"
|
| 496 |
+
)
|
| 497 |
+
|
| 498 |
+
# Sample 3 generations for inspection
|
| 499 |
+
if step % 10 == 0:
|
| 500 |
+
samples = []
|
| 501 |
+
for p_ep, r_ep, rw_ep in all_episodes[:3]:
|
| 502 |
+
if p_ep and r_ep:
|
| 503 |
+
samples.append({
|
| 504 |
+
"step": step,
|
| 505 |
+
"response": r_ep[-1],
|
| 506 |
+
"reward": rw_ep[-1]["total"],
|
| 507 |
+
"reward_detail": rw_ep[-1],
|
| 508 |
+
})
|
| 509 |
+
with open("generations.jsonl", "a", encoding="utf-8") as f:
|
| 510 |
+
for s in samples:
|
| 511 |
+
f.write(json.dumps(s) + "\n")
|
| 512 |
+
|
| 513 |
+
|
| 514 |
+
def log_episode_jsonl(
|
| 515 |
+
episode: int, task: str, level: int, reward: float,
|
| 516 |
+
accuracy_gain: float, steps_used: int, success: bool,
|
| 517 |
+
log_path: str = "logs/training.jsonl",
|
| 518 |
+
):
|
| 519 |
+
"""Write one episode record to JSONL log (read by plot_rewards.py)."""
|
| 520 |
+
import os as _os
|
| 521 |
+
_os.makedirs(_os.path.dirname(log_path), exist_ok=True)
|
| 522 |
+
entry = {
|
| 523 |
+
"ts": time.time(),
|
| 524 |
+
"episode": episode,
|
| 525 |
+
"task": task,
|
| 526 |
+
"level": level,
|
| 527 |
+
"reward": round(reward, 4),
|
| 528 |
+
"accuracy_gain": round(accuracy_gain, 4),
|
| 529 |
+
"steps_used": steps_used,
|
| 530 |
+
"success": success,
|
| 531 |
+
}
|
| 532 |
+
with open(log_path, "a", encoding="utf-8") as f:
|
| 533 |
+
f.write(json.dumps(entry) + "\n")
|
| 534 |
+
|
| 535 |
+
|
| 536 |
+
# ════════════════════════════════════════════════════════
|
| 537 |
+
# GRPO TRAINING LOOP
|
| 538 |
+
# ════════════════════════════════════════════════════════
|
| 539 |
+
|
| 540 |
+
def run_grpo_training(model, tokenizer, resume_from_checkpoint=None):
|
| 541 |
+
print("\n=== PHASE 2: GRPO TRAINING ===")
|
| 542 |
+
if resume_from_checkpoint:
|
| 543 |
+
print(f"Resuming from checkpoint: {resume_from_checkpoint}")
|
| 544 |
+
|
| 545 |
+
scheduler = CurriculumScheduler()
|
| 546 |
+
|
| 547 |
+
grpo_config = GRPOConfig(
|
| 548 |
+
output_dir="./data-centric-checkpoints",
|
| 549 |
+
num_train_epochs=3,
|
| 550 |
+
per_device_train_batch_size=4,
|
| 551 |
+
gradient_accumulation_steps=4,
|
| 552 |
+
learning_rate=5e-6,
|
| 553 |
+
warmup_steps=20,
|
| 554 |
+
logging_steps=10,
|
| 555 |
+
save_steps=50,
|
| 556 |
+
num_generations=4,
|
| 557 |
+
max_completion_length=50, # renamed from max_new_tokens in TRL ≥0.15
|
| 558 |
+
max_prompt_length=900,
|
| 559 |
+
report_to="none",
|
| 560 |
+
)
|
| 561 |
+
|
| 562 |
+
def reward_fn(completions, prompts=None, **kwargs):
|
| 563 |
+
"""
|
| 564 |
+
Reward function called by GRPOTrainer.
|
| 565 |
+
Runs live episodes and returns total reward for each completion.
|
| 566 |
+
"""
|
| 567 |
+
batch_rewards = []
|
| 568 |
+
episodes_this_batch = []
|
| 569 |
+
|
| 570 |
+
for completion in completions:
|
| 571 |
+
# Capture task BEFORE running episode so log reflects what was run
|
| 572 |
+
task = scheduler.get_task()
|
| 573 |
+
seed = random.randint(0, 9999)
|
| 574 |
+
|
| 575 |
+
prompts_ep, responses_ep, rewards_ep = run_episode(
|
| 576 |
+
model, tokenizer, task, seed
|
| 577 |
+
)
|
| 578 |
+
|
| 579 |
+
if rewards_ep:
|
| 580 |
+
final_reward = sum(r["total"] for r in rewards_ep)
|
| 581 |
+
accuracy_gain = sum(r["env"] for r in rewards_ep)
|
| 582 |
+
success = accuracy_gain > 0.05
|
| 583 |
+
# Update curriculum using success-rate based scheduler
|
| 584 |
+
scheduler.record_episode(
|
| 585 |
+
reached_target=success,
|
| 586 |
+
accuracy_gain=accuracy_gain,
|
| 587 |
+
)
|
| 588 |
+
else:
|
| 589 |
+
final_reward = -0.10
|
| 590 |
+
accuracy_gain = 0.0
|
| 591 |
+
success = False
|
| 592 |
+
scheduler.record_episode(reached_target=False, accuracy_gain=0.0)
|
| 593 |
+
|
| 594 |
+
# Write per-episode JSONL record for plot_rewards.py
|
| 595 |
+
log_episode_jsonl(
|
| 596 |
+
episode=scheduler.global_step,
|
| 597 |
+
task=task,
|
| 598 |
+
level=scheduler.current_level,
|
| 599 |
+
reward=final_reward,
|
| 600 |
+
accuracy_gain=accuracy_gain,
|
| 601 |
+
steps_used=len(rewards_ep) if rewards_ep else 0,
|
| 602 |
+
success=success,
|
| 603 |
+
)
|
| 604 |
+
|
| 605 |
+
batch_rewards.append(final_reward)
|
| 606 |
+
episodes_this_batch.append((prompts_ep, responses_ep, rewards_ep))
|
| 607 |
+
|
| 608 |
+
# Log every 10 calls
|
| 609 |
+
if scheduler.global_step % 10 == 0:
|
| 610 |
+
log_training_step(
|
| 611 |
+
scheduler.global_step,
|
| 612 |
+
episodes_this_batch,
|
| 613 |
+
scheduler,
|
| 614 |
+
)
|
| 615 |
+
|
| 616 |
+
return batch_rewards
|
| 617 |
+
|
| 618 |
+
# GRPOTrainer needs a prompt dataset — use SFT data as source
|
| 619 |
+
raw = [json.loads(l) for l in open("sft_data.jsonl", encoding="utf-8")]
|
| 620 |
+
grpo_dataset = Dataset.from_list([
|
| 621 |
+
{"prompt": ex["prompt"]} for ex in raw
|
| 622 |
+
])
|
| 623 |
+
|
| 624 |
+
trainer = GRPOTrainer(
|
| 625 |
+
model=model,
|
| 626 |
+
tokenizer=tokenizer,
|
| 627 |
+
reward_funcs=[reward_fn],
|
| 628 |
+
args=grpo_config,
|
| 629 |
+
train_dataset=grpo_dataset,
|
| 630 |
+
)
|
| 631 |
+
trainer.train(resume_from_checkpoint=resume_from_checkpoint)
|
| 632 |
+
print("GRPO training complete.\n")
|
| 633 |
+
return model
|
| 634 |
+
|
| 635 |
+
|
| 636 |
+
# ════════════════════════════════════════════════════════
|
| 637 |
+
# SAVE MODEL
|
| 638 |
+
# ════════════════════════════════════════════════════════
|
| 639 |
+
|
| 640 |
+
def save_model(model, tokenizer):
|
| 641 |
+
print("Saving model...")
|
| 642 |
+
|
| 643 |
+
# Save LoRA adapter (safe for 4-bit, fast)
|
| 644 |
+
model.save_pretrained("./data-centric-adapter")
|
| 645 |
+
tokenizer.save_pretrained("./data-centric-adapter")
|
| 646 |
+
print("Adapter saved to ./data-centric-adapter")
|
| 647 |
+
|
| 648 |
+
# Save merged 16-bit for inference
|
| 649 |
+
# IMPORTANT: use unsloth's method — NOT naive merge_and_unload()
|
| 650 |
+
# Naive merge on 4-bit model corrupts weights
|
| 651 |
+
model.save_pretrained_merged(
|
| 652 |
+
"./data-centric-merged",
|
| 653 |
+
tokenizer,
|
| 654 |
+
save_method="merged_16bit",
|
| 655 |
+
)
|
| 656 |
+
print("Merged model saved to ./data-centric-merged")
|
| 657 |
+
print("Test inference immediately before demo.")
|
| 658 |
+
|
| 659 |
+
|
| 660 |
+
# ════════════════════════════════════════════════════════
|
| 661 |
+
# MAIN
|
| 662 |
+
# ════════════════════════════════════════════════════════
|
| 663 |
+
|
| 664 |
+
if __name__ == "__main__":
|
| 665 |
+
# Ensure SFT warmup data exists
|
| 666 |
+
if not os.path.exists("sft_data.jsonl"):
|
| 667 |
+
print("Generating SFT data first...")
|
| 668 |
+
subprocess.run(["python", "sft_generator.py"], check=True)
|
| 669 |
+
|
| 670 |
+
# Start environment server
|
| 671 |
+
server_proc = start_server()
|
| 672 |
+
|
| 673 |
+
try:
|
| 674 |
+
# Load base model with LoRA
|
| 675 |
+
model, tokenizer = load_model()
|
| 676 |
+
|
| 677 |
+
# Phase 1: SFT warmup — teaches valid command grammar
|
| 678 |
+
model = run_sft_warmup(model, tokenizer)
|
| 679 |
+
|
| 680 |
+
# Phase 2: GRPO — improves strategy via environment reward
|
| 681 |
+
model = run_grpo_training(model, tokenizer)
|
| 682 |
+
|
| 683 |
+
# Save adapter + merged 16-bit
|
| 684 |
+
save_model(model, tokenizer)
|
| 685 |
+
|
| 686 |
+
print("\nTraining complete. Run eval_data_centric.py next.")
|
| 687 |
+
|
| 688 |
+
finally:
|
| 689 |
+
stop_server(server_proc)
|