Sumanth2377's picture
feat: initial OpenEnv data-cleaning-env submission v1.0.0
9e2419f
Raw
History Blame Contribute Delete
16.3 kB
"""
DataCleaningEnv — the main OpenEnv environment class.
Exposes:
reset(task_name, seed) → ResetResult
step(action) → StepResult
state() → StateResult
All state is held in-memory; one instance = one episode.
"""
from __future__ import annotations
import uuid
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
from environment.actions import ActionHandler, SUPPORTED_ACTIONS
from environment.datasets.generator import (
generate_easy_dataset,
generate_medium_dataset,
generate_hard_dataset,
get_medium_target_schema,
)
from environment.graders.graders import grade_easy, grade_medium, grade_hard
from environment.models import (
ColumnInfo,
DataCleaningAction,
DataCleaningObservation,
DatasetStats,
IssueDetail,
ResetResult,
StateResult,
StepResult,
)
# ============================================================================
# Constants
# ============================================================================
TASK_CONFIG = {
"csv-doctor": {
"description": (
"Fix data quality issues in a 200-row customer dataset. "
"Issues include missing values in age/salary/email, salary stored as a "
"currency string (e.g. '$45,200.00'), float ages instead of integers, "
"duplicate rows, and inconsistently capitalised names and departments "
"with stray whitespace. Use the available cleaning actions to maximise "
"the data quality score."
),
"max_steps": 15,
},
"schema-enforcer": {
"description": (
"Conform a 300-row contacts dataset to a strict target schema. "
"Phone numbers appear in 5 different formats; normalise to (XXX) XXX-XXXX. "
"Dates appear in 4 different formats; normalise to YYYY-MM-DD. "
"Emails have mixed casing and stray whitespace; normalise to lowercase/stripped. "
"Zip codes occasionally include the +4 suffix; keep only the 5-digit prefix. "
"Country codes should be uppercase 2-3 letter codes. "
"First and last names should be title case. "
"The target_schema field in the observation shows the exact expected format per column."
),
"max_steps": 20,
},
"pipeline-debugger": {
"description": (
"Repair a two-table dataset (orders + customers). "
"Issues: ~10% of orders reference non-existent customer_ids (FK violations); "
"~5% of price values are 10-50x outliers; ~5% of quantity values are 20-50x outliers; "
"~8% of orders are implicit duplicates (same data, different order_id); "
"orders lack the customer segment column (obtainable via merge). "
"The grader measures referential integrity, deduplication, outlier removal, "
"AND downstream ML R² improvement. "
"The auxiliary_datasets field contains a sample view of the customers table."
),
"max_steps": 30,
},
}
# Penalty for destructive actions (dropping >30 % of rows in one step)
DESTRUCTION_PENALTY = -0.10
STEP_COST = -0.005
# ============================================================================
# Helper functions
# ============================================================================
def _column_info(df: pd.DataFrame) -> List[ColumnInfo]:
infos = []
for col in df.columns:
series = df[col]
n_null = int(series.isnull().sum())
total = len(series)
null_pct = round(100.0 * n_null / total, 2) if total else 0.0
sample = series.dropna().head(5).tolist()
# Ensure JSON-serialisable
sample = [
v if not isinstance(v, (np.integer, np.floating)) else v.item()
for v in sample
]
issues: List[str] = []
if n_null > 0:
issues.append(f"missing_values ({n_null} nulls, {null_pct:.1f}%)")
# Detect currency strings
if series.dtype == object:
str_vals = series.dropna().astype(str)
if str_vals.str.contains(r"[\$,€£]", regex=True).any():
issues.append("currency_string — should be numeric")
# Detect float age (should be int)
if col == "age" and pd.api.types.is_float_dtype(series):
non_null = series.dropna()
if (non_null % 1 == 0).all():
issues.append("stored_as_float — should be int")
infos.append(
ColumnInfo(
name=col,
dtype=str(series.dtype),
null_count=n_null,
null_pct=null_pct,
unique_count=int(series.nunique()),
sample_values=sample,
detected_issues=issues,
)
)
return infos
def _dataset_stats(df: pd.DataFrame) -> DatasetStats:
total_cells = df.size
missing = int(df.isnull().sum().sum())
dupes = int(df.duplicated().sum())
# Count dtype issues (currency strings)
dtype_issues = 0
for col in df.columns:
if df[col].dtype == object:
str_vals = df[col].dropna().astype(str)
if str_vals.str.contains(r"[\$,€£]", regex=True).any():
dtype_issues += 1
if col == "age" and pd.api.types.is_float_dtype(df[col]):
dtype_issues += 1
# Format violations (basic heuristic: null + type issues)
format_violations = dtype_issues
return DatasetStats(
total_rows=len(df),
total_cols=len(df.columns),
missing_cells=missing,
missing_pct=round(100.0 * missing / total_cells, 2) if total_cells else 0.0,
duplicate_rows=dupes,
dtype_issues=dtype_issues,
format_violations=format_violations,
)
def _detect_issues(df: pd.DataFrame, task_name: str) -> List[IssueDetail]:
issues: List[IssueDetail] = []
# Missing values
for col in df.columns:
n = int(df[col].isnull().sum())
if n > 0:
issues.append(
IssueDetail(
issue_type="missing_values",
column=col,
severity="high" if n / len(df) > 0.10 else "medium",
description=f"{n} missing values ({100*n/len(df):.1f}%)",
affected_rows=n,
)
)
# Duplicates
n_dupes = int(df.duplicated().sum())
if n_dupes:
issues.append(
IssueDetail(
issue_type="duplicate_rows",
severity="medium",
description=f"{n_dupes} duplicate rows detected",
affected_rows=n_dupes,
)
)
# Currency strings
for col in df.columns:
if df[col].dtype == object:
str_vals = df[col].dropna().astype(str)
bad = str_vals.str.contains(r"[\$,€£]", regex=True).sum()
if bad:
issues.append(
IssueDetail(
issue_type="wrong_dtype",
column=col,
severity="high",
description=f"'{col}' contains {bad} currency-formatted strings; expected numeric",
affected_rows=int(bad),
)
)
return issues
# ============================================================================
# Main environment class
# ============================================================================
class DataCleaningEnv:
"""
OpenEnv-compliant Data Cleaning & Preprocessing Environment.
Usage
-----
env = DataCleaningEnv()
reset_result = env.reset(task_name="csv-doctor", seed=42)
step_result = env.step(DataCleaningAction(action_type="drop_duplicates", parameters={}))
state_result = env.state()
"""
SUPPORTED_TASKS = list(TASK_CONFIG.keys())
def __init__(self) -> None:
self._task_name: str = "csv-doctor"
self._seed: int = 42
self._df: pd.DataFrame = pd.DataFrame()
self._aux_dfs: Dict[str, pd.DataFrame] = {}
self._original_orders: Optional[pd.DataFrame] = None
self._handler: Optional[ActionHandler] = None
self._step_count: int = 0
self._done: bool = False
self._dataset_id: str = ""
self._actions_history: List[str] = []
self._current_score: float = 0.0
self._prev_score: float = 0.0
# ------------------------------------------------------------------
# reset()
# ------------------------------------------------------------------
def reset(
self,
task_name: Optional[str] = None,
seed: Optional[int] = None,
) -> ResetResult:
"""Initialise a new episode."""
if task_name is not None:
if task_name not in self.SUPPORTED_TASKS:
raise ValueError(
f"Unknown task '{task_name}'. Supported: {self.SUPPORTED_TASKS}"
)
self._task_name = task_name
if seed is not None:
self._seed = seed
self._step_count = 0
self._done = False
self._dataset_id = str(uuid.uuid4())[:8]
self._actions_history = []
self._current_score = 0.0
self._prev_score = 0.0
self._aux_dfs = {}
self._original_orders = None
# Load dataset
if self._task_name == "csv-doctor":
self._df = generate_easy_dataset(seed=self._seed)
self._handler = ActionHandler(self._df)
elif self._task_name == "schema-enforcer":
self._df = generate_medium_dataset(seed=self._seed)
self._handler = ActionHandler(self._df)
elif self._task_name == "pipeline-debugger":
customers, orders = generate_hard_dataset(seed=self._seed)
self._df = orders.copy()
self._original_orders = orders.copy()
self._aux_dfs = {"customers": customers}
self._handler = ActionHandler(self._df, self._aux_dfs)
# Compute initial score
self._current_score = self._compute_score()
self._prev_score = self._current_score
obs = self._build_observation()
return ResetResult(observation=obs, info={"task": self._task_name, "seed": self._seed})
# ------------------------------------------------------------------
# step()
# ------------------------------------------------------------------
def step(self, action: DataCleaningAction) -> StepResult:
"""Apply *action* and return the new observation, reward, done, info."""
if self._done:
obs = self._build_observation()
return StepResult(
observation=obs,
reward=0.0,
done=True,
info={"error": "Episode already done. Call reset() to start a new episode."},
)
self._step_count += 1
rows_before = len(self._df)
# Execute action
new_df, message, success = self._handler.execute( # type: ignore[union-attr]
action.action_type, action.parameters
)
# Destructive-action penalty
rows_after = len(new_df)
destructive_penalty = 0.0
if rows_before > 0 and (rows_before - rows_after) / rows_before > 0.30:
destructive_penalty = DESTRUCTION_PENALTY
# Update main df
self._df = new_df
self._handler.df = new_df # type: ignore[union-attr]
# Sync aux_dfs for hard task
if self._task_name == "pipeline-debugger":
self._aux_dfs = self._handler.aux_dfs # type: ignore[union-attr]
# Track action history
action_str = f"step={self._step_count}: {action.action_type}({action.parameters}) → {message}"
self._actions_history.append(action_str)
# Compute reward
new_score = self._compute_score()
score_delta = new_score - self._prev_score
reward = float(
np.clip(score_delta + STEP_COST + destructive_penalty, -1.0, 1.0)
)
self._prev_score = new_score
self._current_score = new_score
# Episode termination
max_steps = TASK_CONFIG[self._task_name]["max_steps"]
self._done = (
self._step_count >= max_steps
or self._current_score >= 0.95
)
info: Dict[str, Any] = {
"action_message": message,
"action_success": success,
"score_before": round(self._prev_score, 4),
"score_after": round(new_score, 4),
"score_delta": round(score_delta, 4),
"destructive_penalty": destructive_penalty,
"step_cost": STEP_COST,
"rows_before": rows_before,
"rows_after": rows_after,
}
obs = self._build_observation()
return StepResult(observation=obs, reward=round(reward, 4), done=self._done, info=info)
# ------------------------------------------------------------------
# state()
# ------------------------------------------------------------------
def state(self) -> StateResult:
"""Return a lightweight snapshot of the current episode state."""
return StateResult(
task_name=self._task_name,
step_count=self._step_count,
done=self._done,
current_score=round(self._current_score, 4),
stats=_dataset_stats(self._df),
actions_history=list(self._actions_history),
)
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _compute_score(self) -> float:
import sys
try:
if self._task_name == "csv-doctor":
score, _ = grade_easy(self._df)
elif self._task_name == "schema-enforcer":
score, _ = grade_medium(self._df)
elif self._task_name == "pipeline-debugger":
customers = self._aux_dfs.get("customers", pd.DataFrame())
# Ensure we pass clean copies to avoid pandas/numpy state issues
score, _ = grade_hard(
self._df.copy(),
customers.copy() if len(customers) > 0 else pd.DataFrame(),
(self._original_orders.copy()
if self._original_orders is not None else self._df.copy()),
)
else:
score = 0.0
except Exception as exc:
print(f"[DEBUG] _compute_score exception ({self._task_name}): {exc}", file=sys.stderr)
score = 0.0
return float(np.clip(float(score), 0.0, 1.0))
def _build_observation(self) -> DataCleaningObservation:
config = TASK_CONFIG[self._task_name]
# Auxiliary dataset preview (hard task — show customers column names + 3 rows)
aux_preview: Optional[Dict[str, Any]] = None
if self._task_name == "pipeline-debugger" and "customers" in self._aux_dfs:
cdf = self._aux_dfs["customers"]
aux_preview = {
"customers": {
"columns": list(cdf.columns),
"rows": cdf.head(3).to_dict(orient="records"),
"total_rows": len(cdf),
}
}
# Target schema preview (medium task)
ts: Optional[Dict[str, Any]] = None
if self._task_name == "schema-enforcer":
ts = get_medium_target_schema()
return DataCleaningObservation(
task_name=self._task_name,
task_description=config["description"],
dataset_id=self._dataset_id,
step_count=self._step_count,
columns=_column_info(self._df),
stats=_dataset_stats(self._df),
issues=_detect_issues(self._df, self._task_name),
actions_history=list(self._actions_history),
target_schema=ts,
auxiliary_datasets=aux_preview,
current_score=round(self._current_score, 4),
max_steps=config["max_steps"],
)