data-cleaning-env / inference.py
Sumanth2377's picture
fix: remove emojis from README, strict compliance formats in inference.py
897bdf7
Raw
History Blame Contribute Delete
17.1 kB
"""
inference.py — Baseline inference script for the Data Cleaning OpenEnv environment.
Mandatory configuration (set as environment variables):
API_BASE_URL The LLM API endpoint (default: HuggingFace Inference Router)
MODEL_NAME The model identifier (default: Qwen/Qwen2.5-72B-Instruct)
HF_TOKEN Your HuggingFace API key
Optional:
TASK_NAME Task to run (default: runs all three tasks sequentially)
MAX_STEPS Maximum steps per episode (overrides task default)
STDOUT FORMAT (mandatory — do not alter field names or order):
[START] task=<task_name> env=data-cleaning-env model=<model_name>
[STEP] step=<n> action=<action_str> reward=<0.00> done=<true|false> error=<msg|null>
[END] success=<true|false> steps=<n> score=<score> rewards=<r1,r2,...,rn>
"""
from __future__ import annotations
import json
import os
import sys
import textwrap
from typing import Any, Dict, List, Optional
from openai import OpenAI
# ---------------------------------------------------------------------------
# Import environment directly (no Docker client needed for local/HF execution)
# ---------------------------------------------------------------------------
from environment.env import DataCleaningEnv, TASK_CONFIG
from environment.models import DataCleaningAction, DataCleaningObservation
# ============================================================================
# Configuration
# ============================================================================
API_BASE_URL: str = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
MODEL_NAME: str = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
HF_TOKEN = os.getenv("HF_TOKEN")
BENCHMARK: str = "data-cleaning-env"
# Which tasks to run (comma-separated list, or 'all')
_TASK_ENV = os.getenv("TASK_NAME", "all")
TASKS_TO_RUN: List[str] = (
["csv-doctor", "schema-enforcer", "pipeline-debugger"]
if _TASK_ENV == "all"
else [t.strip() for t in _TASK_ENV.split(",")]
)
SEED: int = int(os.getenv("SEED", "42"))
MAX_STEPS: int = int(os.getenv("MAX_STEPS", "0")) # 0 → use task default
TEMPERATURE: float = float(os.getenv("TEMPERATURE", "0.2"))
MAX_TOKENS: int = int(os.getenv("MAX_TOKENS", "512"))
SUCCESS_THRESHOLD: float = 0.65 # score in [0, 1] that counts as "success"
# ============================================================================
# Logging helpers (mandatory format — do not change)
# ============================================================================
def log_start(task: str, env: str, model: str) -> None:
print(f"[START] task={task} env={env} model={model}", flush=True)
def log_step(
step: int,
action: str,
reward: float,
done: bool,
error: Optional[str],
) -> None:
error_val = error if error else "null"
done_val = str(done).lower()
# Sanitise action string — no newlines allowed on a single [STEP] line
action_clean = action.replace("\n", " ").replace("\r", "")[:200]
print(
f"[STEP] step={step} action={action_clean} "
f"reward={reward:.2f} done={done_val} error={error_val}",
flush=True,
)
def log_end(
success: bool,
steps: int,
score: float,
rewards: List[float],
) -> None:
rewards_str = ",".join(f"{r:.2f}" for r in rewards)
print(
f"[END] success={str(success).lower()} steps={steps} "
f"score={score:.3f} rewards={rewards_str}",
flush=True,
)
# ============================================================================
# Prompt builders
# ============================================================================
SYSTEM_PROMPT = textwrap.dedent("""
You are an expert data scientist operating an AI data cleaning environment.
Your goal is to fix data quality issues in the provided dataset by issuing
data-cleaning actions one at a time.
AVAILABLE ACTIONS (issue one per turn as valid JSON):
fill_missing — fill null values in a column
drop_duplicates — remove duplicate rows
cast_column — change a column's data type
normalize_format — standardise phone/email/date/zip_code/text_case/strip_currency
apply_regex — regex substitution on a column
drop_column — remove a column entirely
drop_rows_by_condition — drop rows matching a condition
clip_outliers — clip statistical outliers (iqr/zscore)
standardize_text — apply strip/lower/upper/title/remove_extra_spaces
fix_referential_integrity — fix foreign-key violations
merge_tables — merge an auxiliary table
RESPONSE FORMAT (respond with ONLY this JSON — no markdown, no explanation):
{
"action_type": "<action_name>",
"parameters": { ... }
}
Examples:
{"action_type": "fill_missing", "parameters": {"column": "age", "strategy": "median"}}
{"action_type": "drop_duplicates", "parameters": {}}
{"action_type": "normalize_format", "parameters": {"column": "phone", "format_type": "phone"}}
{"action_type": "cast_column", "parameters": {"column": "salary", "dtype": "float"}}
{"action_type": "clip_outliers", "parameters": {"column": "price", "method": "iqr", "threshold": 1.5}}
PRIORITIES:
1. Fix the highest-severity issues first.
2. For 'csv-doctor': fix salary currency strings → cast salary, fix age dtype, fill nulls, drop dupes, title case names & strip dept whitespace.
3. For 'schema-enforcer': use normalize_format for phone/email/date/zip_code, then fix country case and name casing.
4. For 'pipeline-debugger': fix FK violations → drop dupes → clip outliers → merge_tables to add segment.
5. Never drop more than 30% of rows in a single action (incurs penalty).
6. Stop issuing redundant actions once an issue is fixed.
""").strip()
def _obs_summary(obs: DataCleaningObservation) -> str:
"""Build a concise observation string for the LLM."""
issues_text = "\n".join(
f" - [{i.severity.upper()}] {i.issue_type}: {i.description}"
+ (f" (column: {i.column})" if i.column else "")
for i in obs.issues[:10]
) or " None detected"
columns_text = "\n".join(
f" {c.name} ({c.dtype}): {c.null_count} nulls, {c.unique_count} unique"
+ (f" | issues: {'; '.join(c.detected_issues)}" if c.detected_issues else "")
for c in obs.columns[:12]
)
history_text = (
"\n".join(f" {h}" for h in obs.actions_history[-5:])
if obs.actions_history
else " None yet"
)
schema_text = ""
if obs.target_schema:
schema_text = "\nTARGET SCHEMA:\n" + json.dumps(obs.target_schema, indent=2)[:800]
aux_text = ""
if obs.auxiliary_datasets:
aux_text = "\nAUXILIARY TABLES (preview):\n" + json.dumps(
obs.auxiliary_datasets, default=str
)[:400]
return textwrap.dedent(f"""
TASK: {obs.task_name}
OBJECTIVE: {obs.task_description[:300]}
DATASET STATS:
Rows: {obs.stats.total_rows} | Cols: {obs.stats.total_cols}
Missing cells: {obs.stats.missing_cells} ({obs.stats.missing_pct:.1f}%)
Duplicate rows: {obs.stats.duplicate_rows}
Dtype issues: {obs.stats.dtype_issues}
CURRENT SCORE: {obs.current_score:.3f} (step {obs.step_count}/{obs.max_steps})
COLUMNS:
{columns_text}
DETECTED ISSUES:
{issues_text}{schema_text}{aux_text}
RECENT ACTIONS:
{history_text}
Issue the next cleaning action as JSON:
""").strip()
def _parse_action(text: str) -> Optional[DataCleaningAction]:
"""Extract a DataCleaningAction from the model's response."""
text = text.strip()
# Try to extract JSON block
try:
# Sometimes the model wraps it in ```json ... ```
if "```" in text:
import re
match = re.search(r"```(?:json)?\s*([\s\S]*?)```", text)
if match:
text = match.group(1).strip()
data = json.loads(text)
return DataCleaningAction(
action_type=data.get("action_type", ""),
parameters=data.get("parameters", {}),
)
except (json.JSONDecodeError, KeyError, ValueError):
return None
def _get_llm_action(
client: OpenAI,
obs: DataCleaningObservation,
step: int,
) -> tuple[Optional[DataCleaningAction], str]:
"""Call the LLM and return (action, raw_text)."""
user_prompt = _obs_summary(obs)
try:
completion = client.chat.completions.create(
model=MODEL_NAME,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
temperature=TEMPERATURE,
max_tokens=MAX_TOKENS,
)
raw = (completion.choices[0].message.content or "").strip()
except Exception as exc:
print(f"[DEBUG] LLM call failed at step {step}: {exc}", flush=True)
raw = ""
action = _parse_action(raw)
return action, raw
# ============================================================================
# Fallback rule-based agent (for when LLM fails or is unavailable)
# ============================================================================
def _rule_based_action(obs: DataCleaningObservation, step: int) -> DataCleaningAction:
"""
Deterministic rule-based agent used as fallback.
Follows a fixed priority queue per task, ensuring reproducible baseline scores.
"""
task = obs.task_name
issues = obs.issues
columns = {c.name: c for c in obs.columns}
if task == "csv-doctor":
# Priority order
if obs.stats.duplicate_rows > 0:
return DataCleaningAction(action_type="drop_duplicates", parameters={})
# Fix salary currency string
if "salary" in columns and any("currency" in i for i in columns["salary"].detected_issues):
return DataCleaningAction(
action_type="normalize_format",
parameters={"column": "salary", "format_type": "strip_currency"},
)
# Fill missing age
if "age" in columns and columns["age"].null_count > 0:
return DataCleaningAction(
action_type="fill_missing",
parameters={"column": "age", "strategy": "median"},
)
# Cast age to int
if "age" in columns and columns["age"].dtype in ("float64", "float32"):
return DataCleaningAction(
action_type="cast_column",
parameters={"column": "age", "dtype": "int"},
)
# Fill missing salary
if "salary" in columns and columns["salary"].null_count > 0:
return DataCleaningAction(
action_type="fill_missing",
parameters={"column": "salary", "strategy": "median"},
)
# Fill missing email
if "email" in columns and columns["email"].null_count > 0:
return DataCleaningAction(
action_type="fill_missing",
parameters={"column": "email", "strategy": "constant", "fill_value": "unknown@example.com"},
)
# Title-case names
if "name" in columns:
return DataCleaningAction(
action_type="standardize_text",
parameters={"column": "name", "operations": ["title"]},
)
# Strip department whitespace
if "department" in columns:
return DataCleaningAction(
action_type="standardize_text",
parameters={"column": "department", "operations": ["strip"]},
)
elif task == "schema-enforcer":
order = [
("phone", "normalize_format", {"column": "phone", "format_type": "phone"}),
("birth_date", "normalize_format", {"column": "birth_date", "format_type": "date"}),
("email", "normalize_format", {"column": "email", "format_type": "email"}),
("zip_code", "normalize_format", {"column": "zip_code", "format_type": "zip_code"}),
("country", "normalize_format", {"column": "country", "format_type": "text_case", "output_format": "upper"}),
("first_name", "standardize_text", {"column": "first_name", "operations": ["title"]}),
("last_name", "standardize_text", {"column": "last_name", "operations": ["title"]}),
]
idx = min(step - 1, len(order) - 1)
col, act, params = order[idx]
return DataCleaningAction(action_type=act, parameters=params)
elif task == "pipeline-debugger":
order = [
("fix_referential_integrity", {"child_column": "customer_id", "parent_table": "customers", "parent_column": "customer_id", "action": "drop"}),
("drop_duplicates", {"subset": ["customer_id", "product", "price", "quantity", "order_date"]}),
("clip_outliers", {"column": "price", "method": "iqr", "threshold": 1.5}),
("clip_outliers", {"column": "quantity", "method": "iqr", "threshold": 1.5}),
("merge_tables", {"right_table": "customers", "left_on": "customer_id", "right_on": "customer_id", "how": "left", "columns": ["segment"]}),
]
idx = min(step - 1, len(order) - 1)
act, params = order[idx]
return DataCleaningAction(action_type=act, parameters=params)
# Fallback no-op: drop duplicates (safe)
return DataCleaningAction(action_type="drop_duplicates", parameters={})
# ============================================================================
# Single-task episode runner
# ============================================================================
def run_task(
env: DataCleaningEnv,
client: Optional[OpenAI],
task_name: str,
) -> Dict[str, Any]:
"""Run one full episode and return result metrics."""
rewards: List[float] = []
steps_taken = 0
score = 0.0
success = False
error_msg: Optional[str] = None
log_start(task=task_name, env=BENCHMARK, model=MODEL_NAME)
max_steps = MAX_STEPS if MAX_STEPS > 0 else TASK_CONFIG[task_name]["max_steps"]
try:
reset_result = env.reset(task_name=task_name, seed=SEED)
obs = reset_result.observation
for step in range(1, max_steps + 1):
if obs.step_count > 0 and step > obs.max_steps:
break
# Try LLM agent first; fall back to rule-based on failure
action: Optional[DataCleaningAction] = None
action_str = ""
if client is not None:
action, raw_text = _get_llm_action(client, obs, step)
action_str = raw_text[:150] if raw_text else "parse_error"
if action is None:
action = _rule_based_action(obs, step)
action_str = f"{action.action_type}({action.parameters})"
step_result = env.step(action)
obs = step_result.observation
reward = step_result.reward
done = step_result.done
err = step_result.info.get("action_message") if not step_result.info.get("action_success", True) else None
rewards.append(reward)
steps_taken = step
log_step(step=step, action=action_str, reward=reward, done=done, error=err)
if done:
break
state = env.state()
score = float(state.current_score)
success = score >= SUCCESS_THRESHOLD
except Exception as exc:
error_msg = str(exc)
print(f"[DEBUG] Episode error: {exc}", flush=True)
score = float(env.state().current_score) if steps_taken > 0 else 0.0
finally:
log_end(success=success, steps=steps_taken, score=score, rewards=rewards)
return {
"task": task_name,
"score": round(score, 4),
"success": success,
"steps": steps_taken,
"rewards": rewards,
"error": error_msg,
}
# ============================================================================
# Main entry point
# ============================================================================
def main() -> None:
# Build OpenAI client (pointing to HF Inference Router or custom endpoint)
try:
client: Optional[OpenAI] = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN)
# Quick connectivity test
client.models.list()
except Exception as exc:
print(f"[DEBUG] LLM client unavailable ({exc}), using rule-based fallback.", flush=True)
client = None
env = DataCleaningEnv()
all_results: List[Dict[str, Any]] = []
for task in TASKS_TO_RUN:
result = run_task(env, client, task)
all_results.append(result)
print("", flush=True) # blank line between tasks
# Summary
avg_score = sum(r["score"] for r in all_results) / len(all_results) if all_results else 0.0
n_success = sum(1 for r in all_results if r["success"])
print(
f"[SUMMARY] tasks={len(all_results)} success={n_success}/{len(all_results)} "
f"avg_score={avg_score:.3f}",
flush=True,
)
if __name__ == "__main__":
main()