Tarkeshwar Claude Opus 4.6 (1M context) commited on
Commit
5523185
·
1 Parent(s): 4812df3

Add top-3 differentiators: seed variation, TRL training, benchmarking

Browse files

1. Seed-based data variation (server/tasks.py):
- get_task(task_id, seed=N) produces randomized episodes
- Corruption functions per issue type: email, phone, date, whitespace,
canonical, number, outlier, duplicate
- Same issue count and validation rules, different corrupted rows
- seed=None returns original data (backward compatible)

2. TRL GRPO training script (train.py):
- DataCleanToolEnv class with individual tool methods (inspect, fix,
delete, submit) that TRL auto-discovers via docstrings
- Plugs into GRPOTrainer with environment_factory pattern
- Supports all 4 tasks with seed variation for training diversity

3. Benchmark script (eval.py):
- Run any model across all tasks, report per-task and average scores
- Multi-seed evaluation for variance measurement
- JSON output for CI/programmatic use

4. README polish:
- HF Space tags: openenv, rl-environment, data-cleaning, trl
- Architecture diagram
- Training, benchmarking, seed variation documentation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Files changed (5) hide show
  1. README.md +71 -0
  2. eval.py +222 -0
  3. server/environment.py +1 -1
  4. server/tasks.py +249 -3
  5. train.py +231 -0
README.md CHANGED
@@ -5,6 +5,12 @@ colorFrom: blue
5
  colorTo: green
6
  sdk: docker
7
  app_port: 8000
 
 
 
 
 
 
8
  ---
9
 
10
  # DataCleanEnv — Data Quality Analysis & Cleaning Environment
@@ -130,6 +136,71 @@ Scores vary by model capability. Expected ranges:
130
  | employee_records (hard) | 0.0–0.1 | 0.1–0.4 |
131
  | financial_transactions (expert) | 0.0–0.1 | 0.1–0.3 |
132
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  ## Technical Details
134
 
135
  - **Framework**: OpenEnv (openenv-core 0.2.3)
 
5
  colorTo: green
6
  sdk: docker
7
  app_port: 8000
8
+ tags:
9
+ - openenv
10
+ - rl-environment
11
+ - data-cleaning
12
+ - evaluation
13
+ - trl
14
  ---
15
 
16
  # DataCleanEnv — Data Quality Analysis & Cleaning Environment
 
136
  | employee_records (hard) | 0.0–0.1 | 0.1–0.4 |
137
  | financial_transactions (expert) | 0.0–0.1 | 0.1–0.3 |
138
 
139
+ ## Seed-Based Data Variation
140
+
141
+ Each task supports reproducible randomized episodes via the `seed` parameter:
142
+
143
+ ```bash
144
+ # Deterministic (original data):
145
+ POST /reset {"task_id": "customer_contacts"}
146
+
147
+ # Randomized variant (same issue types, different corrupted rows):
148
+ POST /reset {"task_id": "customer_contacts", "seed": 42}
149
+ ```
150
+
151
+ This enables RL training with diverse episodes — the agent must learn data cleaning *skills*, not memorize fixed answers.
152
+
153
+ ## Training with TRL (GRPO)
154
+
155
+ The environment integrates with TRL's `GRPOTrainer` via the `DataCleanToolEnv` class in `train.py`:
156
+
157
+ ```bash
158
+ # Start the server
159
+ uvicorn server.app:app --host 0.0.0.0 --port 8000
160
+
161
+ # Run training
162
+ python train.py --model "Qwen/Qwen3-0.6B"
163
+ ```
164
+
165
+ The tool environment exposes `inspect()`, `fix()`, `delete()`, `submit()` as individual methods with docstrings that TRL auto-discovers for function calling.
166
+
167
+ ## Benchmarking
168
+
169
+ Evaluate any model across all tasks:
170
+
171
+ ```bash
172
+ # Single evaluation
173
+ python eval.py --model "meta-llama/Llama-3.1-8B-Instruct"
174
+
175
+ # Multi-seed evaluation (measures variance)
176
+ python eval.py --seeds 5 --json
177
+
178
+ # Specific tasks only
179
+ python eval.py --tasks customer_contacts sales_records
180
+ ```
181
+
182
+ ## Architecture
183
+
184
+ ```
185
+ ┌─────────────────────────────────────────────────┐
186
+ │ DataCleanEnv │
187
+ ├──────────┬──────────┬───────────┬───────────────┤
188
+ │ /reset │ /step │ /ws │ /web/ │
189
+ │ /state │ /health │ /mcp │ /docs │
190
+ ├──────────┴──────────┴───────────┴───────────────┤
191
+ │ server/environment.py — State Machine │
192
+ │ ┌──────────┐ ┌──────────┐ ┌────────────┐ │
193
+ │ │ tasks.py │ │graders.py│ │action_parse│ │
194
+ │ │ 4 tasks │ │12 validators│ │robust parse│ │
195
+ │ │ + seeds │ │ │ │ │ │
196
+ │ └──────────┘ └──────────┘ └────────────┘ │
197
+ ├─────────────────────────────────────────────────┤
198
+ │ inference.py — Plan-Then-Execute Agent │
199
+ │ train.py — TRL GRPO Training Pipeline │
200
+ │ eval.py — Model Benchmarking │
201
+ └─────────────────────────────────────────────────┘
202
+ ```
203
+
204
  ## Technical Details
205
 
206
  - **Framework**: OpenEnv (openenv-core 0.2.3)
eval.py ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Evaluation / Benchmark Script — DataCleanEnv
3
+ =============================================
4
+ Run any model across all tasks and report scores.
5
+
6
+ Usage:
7
+ # Start the environment server first:
8
+ uvicorn server.app:app --host 0.0.0.0 --port 8000
9
+
10
+ # Evaluate with default settings:
11
+ python eval.py
12
+
13
+ # Evaluate a specific model:
14
+ python eval.py --model "meta-llama/Llama-3.1-8B-Instruct"
15
+
16
+ # Evaluate with seed variation (multiple runs):
17
+ python eval.py --seeds 5 --tasks customer_contacts sales_records
18
+
19
+ # JSON output for CI/programmatic use:
20
+ python eval.py --json
21
+
22
+ Environment variables:
23
+ API_BASE_URL LLM API endpoint
24
+ MODEL_NAME Model identifier
25
+ HF_TOKEN API key
26
+ ENV_URL Environment server URL
27
+ """
28
+
29
+ import argparse
30
+ import json
31
+ import os
32
+ import re
33
+ import statistics
34
+ import sys
35
+ import textwrap
36
+ from typing import Any, Dict, List, Optional
37
+
38
+ import requests
39
+ from openai import OpenAI
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Config
43
+ # ---------------------------------------------------------------------------
44
+ API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
45
+ API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY", "")
46
+ MODEL_NAME = os.getenv("MODEL_NAME", "")
47
+ ENV_URL = os.getenv("ENV_URL", "http://localhost:8000")
48
+
49
+ ALL_TASKS = ["customer_contacts", "sales_records", "employee_records", "financial_transactions"]
50
+
51
+ PLANNING_PROMPT = textwrap.dedent("""\
52
+ You are an expert data quality analyst. Analyze the dataset and produce
53
+ a COMPLETE fix plan as a JSON array. Output ONLY the JSON array.
54
+
55
+ Format: [{"action": "fix", "row": N, "column": "col", "value": "val"}, {"action": "delete", "row": N}, ...]
56
+
57
+ Rules: Emails must be user@domain.tld. Dates must be YYYY-MM-DD. Numbers must be positive.
58
+ Use exact canonical forms from the task description. Delete duplicates (highest index first).
59
+ List fixes first, then deletes. Only fix cells with actual issues.
60
+ """)
61
+
62
+
63
+ def env_reset(task_id: str, seed: Optional[int] = None) -> Dict[str, Any]:
64
+ payload: Dict[str, Any] = {"task_id": task_id}
65
+ if seed is not None:
66
+ payload["seed"] = seed
67
+ resp = requests.post(f"{ENV_URL}/reset", json=payload, timeout=30)
68
+ resp.raise_for_status()
69
+ data = resp.json()
70
+ return data.get("observation", data)
71
+
72
+
73
+ def env_step(command: str) -> Dict[str, Any]:
74
+ resp = requests.post(f"{ENV_URL}/step", json={"action": {"command": command}}, timeout=30)
75
+ resp.raise_for_status()
76
+ data = resp.json()
77
+ return data.get("observation", data)
78
+
79
+
80
+ def extract_json_plan(text: str) -> Optional[List[Dict]]:
81
+ text = re.sub(r"^```(?:json)?\s*\n?", "", text.strip())
82
+ text = re.sub(r"\n?```\s*$", "", text.strip())
83
+ try:
84
+ plan = json.loads(text)
85
+ if isinstance(plan, list):
86
+ return plan
87
+ except json.JSONDecodeError:
88
+ pass
89
+ match = re.search(r"\[[\s\S]*\]", text)
90
+ if match:
91
+ try:
92
+ plan = json.loads(match.group())
93
+ if isinstance(plan, list):
94
+ return plan
95
+ except json.JSONDecodeError:
96
+ pass
97
+ return None
98
+
99
+
100
+ def run_task(client: OpenAI, model: str, task_id: str, seed: Optional[int] = None) -> float:
101
+ """Run a single task and return the score."""
102
+ obs = env_reset(task_id, seed=seed)
103
+ if obs.get("done", False):
104
+ return obs.get("current_score", 0.0)
105
+
106
+ # Phase 1: Inspect all columns
107
+ columns = []
108
+ for line in obs.get("column_info", "").strip().splitlines():
109
+ if ":" in line:
110
+ col = line.strip().split(":")[0].strip()
111
+ if col:
112
+ columns.append(col)
113
+
114
+ inspections = []
115
+ for col in columns:
116
+ obs = env_step(f'inspect("{col}")')
117
+ if obs.get("done", False):
118
+ return obs.get("current_score", 0.0)
119
+ inspections.append(f"[{col}]: {obs.get('feedback', '')}")
120
+
121
+ # Phase 2: Plan
122
+ context = (
123
+ f"Task: {obs.get('task_description', '')}\n"
124
+ f"Columns:\n{obs.get('column_info', '')}\n"
125
+ f"Data:\n{obs.get('data_preview', '')}\n\n"
126
+ f"Inspections:\n" + "\n\n".join(inspections) + "\n\n"
127
+ f"Remaining steps: {obs.get('actions_remaining', 0)}. Issues: {obs.get('total_issues', 0)}."
128
+ )
129
+
130
+ try:
131
+ completion = client.chat.completions.create(
132
+ model=model,
133
+ messages=[
134
+ {"role": "system", "content": PLANNING_PROMPT},
135
+ {"role": "user", "content": context},
136
+ ],
137
+ temperature=0.0,
138
+ max_tokens=2000,
139
+ )
140
+ plan = extract_json_plan(completion.choices[0].message.content or "")
141
+ except Exception:
142
+ plan = None
143
+
144
+ # Phase 3: Execute
145
+ if plan:
146
+ for action in plan:
147
+ if obs.get("done", False) or obs.get("actions_remaining", 0) <= 1:
148
+ break
149
+ act_type = action.get("action", "")
150
+ if act_type == "fix":
151
+ cmd = f'fix({action["row"]}, "{action["column"]}", "{action["value"]}")'
152
+ elif act_type == "delete":
153
+ cmd = f'delete({action["row"]})'
154
+ else:
155
+ continue
156
+ obs = env_step(cmd)
157
+
158
+ # Submit
159
+ if not obs.get("done", False):
160
+ obs = env_step("submit()")
161
+
162
+ return obs.get("current_score", 0.0)
163
+
164
+
165
+ def main():
166
+ parser = argparse.ArgumentParser(description="Benchmark models on DataCleanEnv")
167
+ parser.add_argument("--model", default=MODEL_NAME or "meta-llama/Llama-3.1-8B-Instruct")
168
+ parser.add_argument("--tasks", nargs="*", default=ALL_TASKS)
169
+ parser.add_argument("--seeds", type=int, default=1, help="Number of seeds per task (1 = no seed)")
170
+ parser.add_argument("--env-url", default=ENV_URL)
171
+ parser.add_argument("--json", action="store_true", help="Output JSON")
172
+ args = parser.parse_args()
173
+
174
+ global ENV_URL
175
+ ENV_URL = args.env_url
176
+
177
+ client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
178
+ results: Dict[str, List[float]] = {}
179
+
180
+ for task_id in args.tasks:
181
+ scores = []
182
+ seeds = [None] if args.seeds <= 1 else list(range(1, args.seeds + 1))
183
+ for seed in seeds:
184
+ seed_str = f" (seed={seed})" if seed else ""
185
+ if not args.json:
186
+ print(f" Running {task_id}{seed_str}...", end=" ", flush=True)
187
+ score = run_task(client, args.model, task_id, seed=seed)
188
+ scores.append(score)
189
+ if not args.json:
190
+ print(f"{score:.4f}")
191
+ results[task_id] = scores
192
+
193
+ if args.json:
194
+ report = {
195
+ "model": args.model,
196
+ "env_url": args.env_url,
197
+ "results": {
198
+ task: {"scores": scores, "mean": statistics.mean(scores),
199
+ "stdev": statistics.stdev(scores) if len(scores) > 1 else 0.0}
200
+ for task, scores in results.items()
201
+ },
202
+ "average": statistics.mean(s for scores in results.values() for s in scores),
203
+ }
204
+ print(json.dumps(report, indent=2))
205
+ else:
206
+ print(f"\n{'='*60}")
207
+ print(f"BENCHMARK RESULTS — {args.model}")
208
+ print(f"{'='*60}")
209
+ all_scores = []
210
+ for task_id, scores in results.items():
211
+ mean = statistics.mean(scores)
212
+ all_scores.extend(scores)
213
+ if len(scores) > 1:
214
+ sd = statistics.stdev(scores)
215
+ print(f" {task_id:30s} {mean:.4f} ± {sd:.4f} (n={len(scores)})")
216
+ else:
217
+ print(f" {task_id:30s} {mean:.4f}")
218
+ print(f" {'AVERAGE':30s} {statistics.mean(all_scores):.4f}")
219
+
220
+
221
+ if __name__ == "__main__":
222
+ main()
server/environment.py CHANGED
@@ -140,7 +140,7 @@ class DataCleanEnvironment(Environment):
140
  **kwargs: Any,
141
  ) -> DataCleanObservation:
142
  task_id = kwargs.get("task_id", "customer_contacts")
143
- self._task = get_task(task_id)
144
 
145
  self._current_data = copy.deepcopy(self._task.data)
146
  self._issue_status = {issue.issue_id: False for issue in self._task.issues}
 
140
  **kwargs: Any,
141
  ) -> DataCleanObservation:
142
  task_id = kwargs.get("task_id", "customer_contacts")
143
+ self._task = get_task(task_id, seed=seed)
144
 
145
  self._current_data = copy.deepcopy(self._task.data)
146
  self._issue_status = {issue.issue_id: False for issue in self._task.issues}
server/tasks.py CHANGED
@@ -417,10 +417,256 @@ ALL_TASKS: Dict[str, TaskDefinition] = {
417
  }
418
 
419
 
420
- def get_task(task_id: str) -> TaskDefinition:
421
- """Get a deep copy of a task definition."""
 
 
 
 
 
422
  if task_id not in ALL_TASKS:
423
  raise ValueError(
424
  f"Unknown task_id '{task_id}'. Available: {list(ALL_TASKS.keys())}"
425
  )
426
- return copy.deepcopy(ALL_TASKS[task_id])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
417
  }
418
 
419
 
420
+ def get_task(task_id: str, seed: int | None = None) -> TaskDefinition:
421
+ """Get a task definition. With seed, produces a randomized variant.
422
+
423
+ When seed is None, returns the original hardcoded task (deterministic).
424
+ When seed is provided, corrupts random clean rows to create variation
425
+ while keeping the same number of issues and validation rules.
426
+ """
427
  if task_id not in ALL_TASKS:
428
  raise ValueError(
429
  f"Unknown task_id '{task_id}'. Available: {list(ALL_TASKS.keys())}"
430
  )
431
+ base = copy.deepcopy(ALL_TASKS[task_id])
432
+ if seed is None:
433
+ return base
434
+ return _generate_seeded_task(base, seed)
435
+
436
+
437
+ # ---------------------------------------------------------------------------
438
+ # Seed-based procedural data variation
439
+ # ---------------------------------------------------------------------------
440
+ import random as _random_module
441
+
442
+
443
+ def _corrupt_email(rng: _random_module.Random, value: str) -> str:
444
+ """Corrupt a valid email into an invalid one."""
445
+ corruptions = [
446
+ lambda v: v.replace("@", "[at]"),
447
+ lambda v: v.replace("@", "@@"),
448
+ lambda v: v.split("@")[0], # missing domain
449
+ lambda v: v.replace(".", " ", 1),
450
+ lambda v: " " + v + " ",
451
+ ]
452
+ return rng.choice(corruptions)(value)
453
+
454
+
455
+ def _corrupt_phone(rng: _random_module.Random, value: str) -> str:
456
+ """Inject letters into a phone number."""
457
+ chars = list(value)
458
+ positions = [i for i, c in enumerate(chars) if c.isdigit()]
459
+ if len(positions) >= 3:
460
+ for pos in rng.sample(positions, min(3, len(positions))):
461
+ chars[pos] = rng.choice("ABCDEFX")
462
+ return "".join(chars)
463
+
464
+
465
+ def _corrupt_date(rng: _random_module.Random, value: str) -> tuple[str, str]:
466
+ """Corrupt a YYYY-MM-DD date. Returns (corrupted_value, issue_type)."""
467
+ corruptions = [
468
+ (lambda v: f"{v[5:7]}/{v[8:10]}/{v[:4]}", "wrong_date_format"), # MM/DD/YYYY
469
+ (lambda v: v.replace("-", "/"), "wrong_date_format"), # slashes
470
+ (lambda v: v[:5] + "13" + v[7:], "invalid_date"), # month 13
471
+ ]
472
+ func, issue_type = rng.choice(corruptions)
473
+ return func(value), issue_type
474
+
475
+
476
+ def _corrupt_whitespace(rng: _random_module.Random, value: str) -> str:
477
+ """Add excess whitespace to a string value."""
478
+ corruptions = [
479
+ lambda v: " " + v + " ",
480
+ lambda v: v.replace(" ", " ", 1) if " " in v else " " + v,
481
+ lambda v: v + " ",
482
+ ]
483
+ return rng.choice(corruptions)(value)
484
+
485
+
486
+ def _corrupt_canonical(rng: _random_module.Random, value: str, canonical_set: set) -> str:
487
+ """Produce a non-canonical variant of a valid value."""
488
+ corruptions = [
489
+ lambda v: v.lower(),
490
+ lambda v: v.upper(),
491
+ lambda v: v.replace(" ", "-").lower(),
492
+ lambda v: v[:3], # abbreviation
493
+ ]
494
+ corrupted = rng.choice(corruptions)(value)
495
+ # Make sure it's actually different from all canonical values
496
+ if corrupted in canonical_set:
497
+ corrupted = corrupted + " (typo)"
498
+ return corrupted
499
+
500
+
501
+ def _corrupt_number_negative(rng: _random_module.Random, value: float) -> float:
502
+ """Make a positive number negative."""
503
+ return -abs(value)
504
+
505
+
506
+ def _corrupt_number_outlier(rng: _random_module.Random, value: float, low: float, high: float) -> float:
507
+ """Push a number outside the valid range."""
508
+ if rng.random() < 0.5:
509
+ return high * rng.uniform(10, 1000) # way above
510
+ else:
511
+ return low * rng.uniform(-10, -0.1) # way below or negative
512
+
513
+
514
+ def _generate_seeded_task(base: TaskDefinition, seed: int) -> TaskDefinition:
515
+ """Generate a randomized variant of a task using a seed.
516
+
517
+ Strategy: For each issue in the base task, pick a different clean row
518
+ to corrupt (when possible) and apply the same type of corruption.
519
+ This keeps issue count and types identical but changes which rows
520
+ are affected and how they're corrupted.
521
+ """
522
+ rng = _random_module.Random(seed)
523
+
524
+ # Find which rows have issues in the base task
525
+ issue_rows = {issue.row for issue in base.issues}
526
+ clean_rows = [i for i in range(len(base.data)) if i not in issue_rows]
527
+
528
+ # Start with clean versions of all data
529
+ # First, build a "clean" dataset by reverting corrupted cells
530
+ # For simplicity, we'll reuse the base data but re-assign which rows get corrupted
531
+ data = copy.deepcopy(base.data)
532
+ new_issues: List[Issue] = []
533
+ issue_counter = 0
534
+
535
+ # Separate non-duplicate issues from duplicate issues
536
+ non_dup_issues = [i for i in base.issues if i.issue_type != "duplicate_row"]
537
+ dup_issues = [i for i in base.issues if i.issue_type == "duplicate_row"]
538
+
539
+ # For non-duplicate issues: try to assign to different rows
540
+ available_rows = list(range(len(data)))
541
+ rng.shuffle(available_rows)
542
+ used_rows: set = set()
543
+
544
+ for orig_issue in non_dup_issues:
545
+ issue_counter += 1
546
+ issue_id = f"S{seed}-{issue_counter}"
547
+ col = orig_issue.column
548
+ issue_type = orig_issue.issue_type
549
+
550
+ # Pick a target row (prefer one not already used)
551
+ candidates = [r for r in available_rows if r not in used_rows and r < len(data)]
552
+ if not candidates:
553
+ candidates = [r for r in range(len(data)) if r not in used_rows]
554
+ if not candidates:
555
+ # All rows used, just reuse the original
556
+ new_issues.append(Issue(
557
+ issue_id=issue_id, row=orig_issue.row, column=col,
558
+ issue_type=issue_type, description=orig_issue.description,
559
+ validation_params=copy.deepcopy(orig_issue.validation_params),
560
+ ))
561
+ continue
562
+
563
+ target_row = rng.choice(candidates)
564
+ used_rows.add(target_row)
565
+ original_value = data[target_row].get(col, "")
566
+
567
+ # Apply corruption based on issue type
568
+ description = orig_issue.description
569
+ params = copy.deepcopy(orig_issue.validation_params)
570
+
571
+ if issue_type == "invalid_email" and original_value:
572
+ if "@" in str(original_value):
573
+ data[target_row][col] = _corrupt_email(rng, str(original_value))
574
+ description = f"Email '{data[target_row][col]}' is invalid"
575
+ else:
576
+ target_row = orig_issue.row # fallback to original
577
+ description = orig_issue.description
578
+
579
+ elif issue_type == "invalid_phone" and original_value:
580
+ data[target_row][col] = _corrupt_phone(rng, str(original_value))
581
+ description = f"Phone contains non-numeric characters"
582
+
583
+ elif issue_type in ("wrong_date_format", "invalid_date") and original_value:
584
+ try:
585
+ corrupted, actual_type = _corrupt_date(rng, str(original_value))
586
+ data[target_row][col] = corrupted
587
+ issue_type = actual_type
588
+ description = f"Date '{corrupted}' is not valid YYYY-MM-DD"
589
+ except (IndexError, ValueError):
590
+ target_row = orig_issue.row
591
+
592
+ elif issue_type == "missing_value":
593
+ data[target_row][col] = ""
594
+ description = f"Value in column '{col}' is empty"
595
+
596
+ elif issue_type == "negative_number" and original_value:
597
+ try:
598
+ val = float(original_value)
599
+ if val > 0:
600
+ data[target_row][col] = _corrupt_number_negative(rng, val)
601
+ description = f"Value is negative ({data[target_row][col]})"
602
+ else:
603
+ target_row = orig_issue.row
604
+ except (ValueError, TypeError):
605
+ target_row = orig_issue.row
606
+
607
+ elif issue_type == "outlier" and original_value:
608
+ low = params.get("low", 0)
609
+ high = params.get("high", 100)
610
+ try:
611
+ val = float(original_value)
612
+ data[target_row][col] = round(_corrupt_number_outlier(rng, val, low, high), 2)
613
+ description = f"Value {data[target_row][col]} is outside range [{low}, {high}]"
614
+ except (ValueError, TypeError):
615
+ target_row = orig_issue.row
616
+
617
+ elif issue_type == "inconsistent_format" and original_value:
618
+ canonical_set = params.get("canonical_set", set())
619
+ if str(original_value) in canonical_set:
620
+ data[target_row][col] = _corrupt_canonical(rng, str(original_value), canonical_set)
621
+ description = f"Value '{data[target_row][col]}' doesn't match canonical form"
622
+ else:
623
+ target_row = orig_issue.row
624
+
625
+ elif issue_type == "excess_whitespace" and original_value:
626
+ data[target_row][col] = _corrupt_whitespace(rng, str(original_value))
627
+ description = f"Excess whitespace in '{col}'"
628
+
629
+ elif issue_type == "score_out_of_range" and original_value:
630
+ low = params.get("low", 0)
631
+ high = params.get("high", 10)
632
+ bad_val = rng.choice([rng.uniform(-5, low - 0.1), rng.uniform(high + 0.1, high + 10)])
633
+ data[target_row][col] = round(bad_val, 1)
634
+ description = f"Score {data[target_row][col]} is outside range [{low}, {high}]"
635
+
636
+ elif issue_type in ("referential_integrity", "temporal_inconsistency", "cross_column_violation"):
637
+ # Complex types: keep original row assignment
638
+ target_row = orig_issue.row
639
+ description = orig_issue.description
640
+
641
+ else:
642
+ # Unknown type or can't corrupt: keep original
643
+ target_row = orig_issue.row
644
+ description = orig_issue.description
645
+
646
+ new_issues.append(Issue(
647
+ issue_id=issue_id, row=target_row, column=col,
648
+ issue_type=issue_type, description=description,
649
+ validation_params=params,
650
+ ))
651
+
652
+ # Handle duplicate issues: pick a random clean row to duplicate
653
+ for orig_dup in dup_issues:
654
+ issue_counter += 1
655
+ issue_id = f"S{seed}-{issue_counter}"
656
+ # Pick a random row to duplicate at the end
657
+ source_row = rng.randint(0, len(data) - 1)
658
+ dup_data = copy.deepcopy(data[source_row])
659
+ dup_row_idx = len(data)
660
+ data.append(dup_data)
661
+ new_issues.append(Issue(
662
+ issue_id=issue_id, row=dup_row_idx, column="",
663
+ issue_type="duplicate_row",
664
+ description=f"Duplicate of row {source_row}",
665
+ original_row_data=copy.deepcopy(dup_data),
666
+ ))
667
+
668
+ # Rebuild the task with seeded data
669
+ base.data = data
670
+ base.issues = new_issues
671
+ base.max_steps = max(base.max_steps, len(new_issues) * 2 + len(base.columns) + 1)
672
+ return base
train.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Training Script — DataCleanEnv + TRL GRPO
3
+ ==========================================
4
+ Train an LLM agent to clean data using Group Relative Policy Optimization.
5
+
6
+ Prerequisites:
7
+ pip install trl datasets transformers torch
8
+
9
+ Usage:
10
+ # Start the environment server first:
11
+ uvicorn server.app:app --host 0.0.0.0 --port 8000
12
+
13
+ # Then run training:
14
+ python train.py
15
+
16
+ # With custom model:
17
+ python train.py --model "Qwen/Qwen3-0.6B" --env-url "http://localhost:8000"
18
+
19
+ Environment variables:
20
+ ENV_URL Environment server URL (default: http://localhost:8000)
21
+ """
22
+
23
+ import argparse
24
+ import os
25
+ from typing import List
26
+
27
+ import requests
28
+
29
+ ENV_URL = os.getenv("ENV_URL", "http://localhost:8000")
30
+
31
+
32
+ class DataCleanToolEnv:
33
+ """TRL-compatible environment factory for data cleaning.
34
+
35
+ Exposes data cleaning operations as individual tool methods with
36
+ docstrings that TRL's GRPOTrainer auto-discovers for function calling.
37
+
38
+ Each tool method communicates with the running DataCleanEnv server
39
+ and updates self.reward with the current episode score.
40
+ """
41
+
42
+ def __init__(self):
43
+ self.reward = 0.0
44
+ self._env_url = ENV_URL
45
+ self._task_id = "customer_contacts"
46
+ self._seed = None
47
+
48
+ def _step(self, command: str) -> str:
49
+ resp = requests.post(
50
+ f"{self._env_url}/step",
51
+ json={"action": {"command": command}},
52
+ timeout=30,
53
+ )
54
+ resp.raise_for_status()
55
+ data = resp.json()
56
+ obs = data.get("observation", data)
57
+ self.reward = obs.get("current_score", 0.0)
58
+ return obs.get("feedback", "")
59
+
60
+ def reset(self, **kwargs) -> str:
61
+ """Reset the environment with a new data cleaning task.
62
+
63
+ Returns the task description, column info, and full data table
64
+ so the agent has complete context for planning fixes.
65
+ """
66
+ self._task_id = kwargs.get("task_id", self._task_id)
67
+ self._seed = kwargs.get("seed", None)
68
+ self.reward = 0.0
69
+
70
+ payload = {"task_id": self._task_id}
71
+ if self._seed is not None:
72
+ payload["seed"] = self._seed
73
+
74
+ resp = requests.post(
75
+ f"{self._env_url}/reset",
76
+ json=payload,
77
+ timeout=30,
78
+ )
79
+ resp.raise_for_status()
80
+ data = resp.json()
81
+ obs = data.get("observation", data)
82
+
83
+ return (
84
+ f"Task: {obs.get('task_description', '')}\n\n"
85
+ f"Columns:\n{obs.get('column_info', '')}\n\n"
86
+ f"Data:\n{obs.get('data_preview', '')}\n\n"
87
+ f"Total issues to fix: {obs.get('total_issues', 0)}. "
88
+ f"Actions remaining: {obs.get('actions_remaining', 0)}."
89
+ )
90
+
91
+ def inspect(self, column: str) -> str:
92
+ """Inspect a column to see statistics and detect data quality issues.
93
+
94
+ Use this to understand the data before fixing. Returns column statistics
95
+ including row count, unique values, suspicious entries, and issue hints.
96
+
97
+ Args:
98
+ column: The column name to inspect (e.g., "email", "phone", "salary")
99
+
100
+ Returns:
101
+ Column statistics and quality issue indicators.
102
+ """
103
+ return self._step(f'inspect("{column}")')
104
+
105
+ def fix(self, row: int, column: str, value: str) -> str:
106
+ """Fix a data quality issue by correcting a cell value.
107
+
108
+ Use this after identifying issues via inspect(). Provide the corrected
109
+ value that satisfies the column's validation rules.
110
+
111
+ Args:
112
+ row: The row index (0-based) of the cell to fix
113
+ column: The column name of the cell to fix
114
+ value: The corrected value to set
115
+
116
+ Returns:
117
+ Confirmation of the fix, whether the issue was resolved, and updated score.
118
+ """
119
+ return self._step(f'fix({row}, "{column}", "{value}")')
120
+
121
+ def delete(self, row: int) -> str:
122
+ """Delete a duplicate or invalid row from the dataset.
123
+
124
+ Use this only for rows that are exact duplicates. Delete from highest
125
+ index to lowest to avoid index shifting issues.
126
+
127
+ Args:
128
+ row: The row index (0-based) to delete
129
+
130
+ Returns:
131
+ Confirmation of deletion and whether it was a valid duplicate removal.
132
+ """
133
+ return self._step(f"delete({row})")
134
+
135
+ def submit(self) -> str:
136
+ """Submit the cleaned dataset for final scoring.
137
+
138
+ Call this after fixing all identified issues. Returns the final score
139
+ and summary of what was fixed vs. missed.
140
+
141
+ Returns:
142
+ Final score and episode summary.
143
+ """
144
+ return self._step("submit()")
145
+
146
+
147
+ def reward_func(environments: List[DataCleanToolEnv], **kwargs) -> List[float]:
148
+ """Extract rewards from completed environments."""
149
+ return [env.reward for env in environments]
150
+
151
+
152
+ def main():
153
+ parser = argparse.ArgumentParser(description="Train a data cleaning agent with TRL GRPO")
154
+ parser.add_argument("--model", default="Qwen/Qwen3-0.6B", help="Model to fine-tune")
155
+ parser.add_argument("--env-url", default=ENV_URL, help="Environment server URL")
156
+ parser.add_argument("--num-episodes", type=int, default=64, help="Training episodes")
157
+ parser.add_argument("--output-dir", default="./output", help="Output directory")
158
+ args = parser.parse_args()
159
+
160
+ global ENV_URL
161
+ ENV_URL = args.env_url
162
+
163
+ try:
164
+ from datasets import Dataset
165
+ from trl import GRPOConfig, GRPOTrainer
166
+ except ImportError:
167
+ print("TRL not installed. Install with: pip install trl datasets transformers torch")
168
+ print("\nThis script requires a GPU for training. The DataCleanToolEnv class")
169
+ print("can also be used standalone for agent evaluation:")
170
+ print("\n env = DataCleanToolEnv()")
171
+ print(' obs = env.reset(task_id="customer_contacts", seed=42)')
172
+ print(' result = env.inspect("email")')
173
+ print(' result = env.fix(3, "email", "alice@mail.com")')
174
+ print(' result = env.submit()')
175
+ print(f" print(env.reward) # -> score between 0.0 and 1.0")
176
+ return
177
+
178
+ # Build training dataset with prompts for each difficulty level
179
+ tasks = ["customer_contacts", "sales_records", "employee_records", "financial_transactions"]
180
+ n_per_task = args.num_episodes // len(tasks)
181
+
182
+ prompts = []
183
+ task_ids = []
184
+ seeds = []
185
+ for task_id in tasks:
186
+ for i in range(n_per_task):
187
+ prompts.append([{
188
+ "role": "user",
189
+ "content": (
190
+ f"Clean the {task_id.replace('_', ' ')} dataset. "
191
+ "Inspect columns to find issues, fix all data quality problems, "
192
+ "delete duplicates, then submit for scoring. "
193
+ "Be precise and conservative — wrong fixes are penalized."
194
+ ),
195
+ }])
196
+ task_ids.append(task_id)
197
+ seeds.append(i + 1) # Different seed per episode for diversity
198
+
199
+ dataset = Dataset.from_dict({
200
+ "prompt": prompts,
201
+ "task_id": task_ids,
202
+ "seed": seeds,
203
+ })
204
+
205
+ print(f"Training {args.model} on {len(dataset)} episodes across {len(tasks)} tasks")
206
+ print(f"Environment: {args.env_url}")
207
+
208
+ trainer = GRPOTrainer(
209
+ model=args.model,
210
+ train_dataset=dataset,
211
+ reward_funcs=reward_func,
212
+ args=GRPOConfig(
213
+ output_dir=args.output_dir,
214
+ max_completion_length=4096,
215
+ num_generations=4,
216
+ per_device_train_batch_size=1,
217
+ gradient_accumulation_steps=4,
218
+ logging_steps=1,
219
+ log_completions=True,
220
+ report_to="none",
221
+ ),
222
+ environment_factory=DataCleanToolEnv,
223
+ )
224
+
225
+ trainer.train()
226
+ trainer.save_model(args.output_dir)
227
+ print(f"Model saved to {args.output_dir}")
228
+
229
+
230
+ if __name__ == "__main__":
231
+ main()