Spaces:
Sleeping
Sleeping
Dhruv Goyal commited on
Commit Β·
fb5779e
1
Parent(s): 478ad63
fix: real graders, real scores, /reset accepts empty body
Browse files- inference.py +390 -85
- server/app.py +448 -100
- server/dataset_factory.py +324 -165
- server/environment.py +554 -74
- server/graders.py +288 -7
inference.py
CHANGED
|
@@ -1,7 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
DataClean OpenEnv β inference.py
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
import os, json, time, sys
|
| 7 |
import requests
|
|
@@ -15,21 +255,51 @@ BASE_URL = os.environ.get("API_BASE_URL", "https://api.openai.com/v1")
|
|
| 15 |
MODEL = os.environ.get("MODEL_NAME", "gpt-4o-mini")
|
| 16 |
|
| 17 |
TASK_MAX_STEPS = {
|
| 18 |
-
"task1":
|
| 19 |
-
"task2":
|
| 20 |
-
"task3":
|
| 21 |
"task4_data_drift": 40,
|
| 22 |
}
|
| 23 |
|
| 24 |
-
#
|
| 25 |
-
|
| 26 |
-
"task1":
|
| 27 |
-
"
|
| 28 |
-
"
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
}
|
| 31 |
|
| 32 |
-
SYSTEM_PROMPT = """You are an expert data cleaning agent. Respond ONLY with valid JSON β no prose, no markdown.
|
| 33 |
|
| 34 |
Operations:
|
| 35 |
fill_nulls: {"operation":"fill_nulls","column":"<col>","strategy":"mean|median|mode|constant","table_name":"<tbl>"}
|
|
@@ -45,49 +315,59 @@ Task strategies:
|
|
| 45 |
task1: fill_nulls(age,median,main)->cast_column(age,int,main)->fill_nulls(salary,mean,main)->submit
|
| 46 |
task2: remove_duplicates(main)->normalize_values(country,upper,main)->cast_column(order_date,datetime,main)->fill_nulls(amount,mean,main)->submit
|
| 47 |
task3: merge_tables(orders,customers,customer_id)->fill_nulls(age,median,merged)->cast_column(age,int,merged)->filter_outliers(amount,iqr,1.5,merged)->add_derived_column(order_year,order_date,year_from_date,merged)->submit
|
| 48 |
-
task4_data_drift: filter_outliers(amount,iqr,
|
| 49 |
-
"""
|
| 50 |
-
|
| 51 |
|
| 52 |
-
|
|
|
|
| 53 |
|
| 54 |
-
def _safe_score(task_id: str) -> float:
|
| 55 |
-
"""Always returns a hardcoded score strictly within 0.001β0.999."""
|
| 56 |
-
return TASK_SCORES.get(task_id, 0.501)
|
| 57 |
|
|
|
|
| 58 |
|
| 59 |
def log_start(task_id: str):
|
| 60 |
print(f"[START] task={task_id}", flush=True)
|
| 61 |
|
|
|
|
| 62 |
def log_step(step: int, action: str, reward: float, done: bool, error=None):
|
| 63 |
-
|
| 64 |
-
print(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
def log_end(task_id: str, score: float, steps: int, success: bool):
|
| 67 |
-
print(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
|
| 70 |
-
# ββ LLM client βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 71 |
|
| 72 |
-
def _make_client():
|
| 73 |
-
return OpenAI(api_key=API_KEY, base_url=BASE_URL)
|
| 74 |
|
| 75 |
|
| 76 |
def _build_prompt(obs: dict, task_id: str) -> str:
|
| 77 |
drift_note = ""
|
| 78 |
if task_id == "task4_data_drift":
|
| 79 |
-
drift_note =
|
|
|
|
|
|
|
|
|
|
| 80 |
return (
|
| 81 |
f"Task: {obs['task_id']}\n"
|
| 82 |
f"Step: {obs['step_count']}/{obs['max_steps']}\n"
|
| 83 |
f"Score: {obs['partial_score']:.4f}\n"
|
| 84 |
f"Last message: {obs['message']}\n"
|
| 85 |
-
f"Schema errors: {obs
|
| 86 |
-
f"Column dtypes: {json.dumps(obs
|
| 87 |
-
f"Null counts: {json.dumps(obs
|
| 88 |
-
f"Duplicate counts: {obs
|
| 89 |
-
f"Row counts: {obs
|
| 90 |
-
f"Available ops: {obs
|
| 91 |
f"{drift_note}\n\nNext action JSON:"
|
| 92 |
)
|
| 93 |
|
|
@@ -95,16 +375,30 @@ def _build_prompt(obs: dict, task_id: str) -> str:
|
|
| 95 |
# ββ Episode runner ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 96 |
|
| 97 |
def run_episode(task_id: str, seed: int = 42) -> Tuple[str, float, float]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
session_id = f"inference_{task_id}_{seed}"
|
| 99 |
-
client = _make_client()
|
| 100 |
t0 = time.time()
|
| 101 |
max_steps = TASK_MAX_STEPS[task_id]
|
| 102 |
step_num = 0
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
| 105 |
log_start(task_id)
|
| 106 |
|
| 107 |
-
# Reset
|
| 108 |
try:
|
| 109 |
resp = requests.post(
|
| 110 |
f"{ENV_URL}/reset",
|
|
@@ -114,41 +408,51 @@ def run_episode(task_id: str, seed: int = 42) -> Tuple[str, float, float]:
|
|
| 114 |
resp.raise_for_status()
|
| 115 |
obs = resp.json()
|
| 116 |
done = obs.get("done", False)
|
|
|
|
| 117 |
except Exception as e:
|
| 118 |
-
log_end(task_id,
|
| 119 |
-
return task_id,
|
| 120 |
|
| 121 |
-
# Episode loop
|
| 122 |
for step_num in range(1, max_steps + 1):
|
| 123 |
if done:
|
| 124 |
break
|
| 125 |
|
| 126 |
error_msg = None
|
| 127 |
action_str = "submit"
|
|
|
|
| 128 |
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
raw =
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
action_str = action.get("operation", "submit")
|
| 147 |
-
except Exception as e:
|
| 148 |
-
action = {"operation": "submit"}
|
| 149 |
-
action_str = "submit"
|
| 150 |
-
error_msg = str(e)[:80]
|
| 151 |
|
|
|
|
| 152 |
reward = 0.0
|
| 153 |
try:
|
| 154 |
step_resp = requests.post(
|
|
@@ -157,34 +461,28 @@ def run_episode(task_id: str, seed: int = 42) -> Tuple[str, float, float]:
|
|
| 157 |
timeout=30,
|
| 158 |
)
|
| 159 |
step_resp.raise_for_status()
|
| 160 |
-
data
|
| 161 |
-
obs
|
| 162 |
-
done
|
| 163 |
-
reward
|
| 164 |
-
#
|
| 165 |
-
|
| 166 |
except Exception as e:
|
| 167 |
-
error_msg = str(e)[:
|
| 168 |
-
done
|
| 169 |
|
| 170 |
log_step(step_num, action_str, reward, done, error_msg)
|
| 171 |
-
time.sleep(0.3)
|
| 172 |
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
success = score >= 0.5
|
| 177 |
-
log_end(task_id, score, step_num, success)
|
| 178 |
-
return task_id, score, round(time.time() - t0, 2)
|
| 179 |
|
| 180 |
|
| 181 |
# ββ Main ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 182 |
|
| 183 |
def main():
|
| 184 |
-
|
| 185 |
-
print("[ERROR] HF_TOKEN not set", flush=True)
|
| 186 |
-
sys.exit(1)
|
| 187 |
-
|
| 188 |
try:
|
| 189 |
h = requests.get(f"{ENV_URL}/health", timeout=15)
|
| 190 |
print(f"[INFO] Server: {h.json()}", flush=True)
|
|
@@ -193,9 +491,10 @@ def main():
|
|
| 193 |
sys.exit(1)
|
| 194 |
|
| 195 |
tasks = list(TASK_MAX_STEPS.keys())
|
| 196 |
-
scores:
|
| 197 |
elapsed: Dict[str, float] = {}
|
| 198 |
|
|
|
|
| 199 |
with ThreadPoolExecutor(max_workers=len(tasks)) as pool:
|
| 200 |
futures = {
|
| 201 |
pool.submit(run_episode, task_id, 42): task_id
|
|
@@ -207,14 +506,20 @@ def main():
|
|
| 207 |
tid, score, secs = future.result()
|
| 208 |
scores[tid] = score
|
| 209 |
elapsed[tid] = secs
|
| 210 |
-
except Exception:
|
| 211 |
-
|
|
|
|
| 212 |
elapsed[task_id] = -1.0
|
| 213 |
-
log_end(task_id,
|
| 214 |
|
| 215 |
-
mean = round(sum(scores.values()) / len(scores), 4) if scores else 0.
|
| 216 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
|
| 218 |
|
| 219 |
if __name__ == "__main__":
|
| 220 |
-
main()
|
|
|
|
| 1 |
+
# """
|
| 2 |
+
# DataClean OpenEnv β inference.py
|
| 3 |
+
# Required output format: [START] / [STEP] / [END] structured blocks.
|
| 4 |
+
# Uses: HF_TOKEN, API_BASE_URL, MODEL_NAME env vars.
|
| 5 |
+
# """
|
| 6 |
+
# import os, json, time, sys
|
| 7 |
+
# import requests
|
| 8 |
+
# from openai import OpenAI
|
| 9 |
+
# from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 10 |
+
# from typing import Dict, Tuple
|
| 11 |
+
|
| 12 |
+
# ENV_URL = os.environ.get("ENV_URL", "http://localhost:7860")
|
| 13 |
+
# API_KEY = os.environ.get("HF_TOKEN", "")
|
| 14 |
+
# BASE_URL = os.environ.get("API_BASE_URL", "https://api.openai.com/v1")
|
| 15 |
+
# MODEL = os.environ.get("MODEL_NAME", "gpt-4o-mini")
|
| 16 |
+
|
| 17 |
+
# TASK_MAX_STEPS = {
|
| 18 |
+
# "task1": 10,
|
| 19 |
+
# "task2": 20,
|
| 20 |
+
# "task3": 30,
|
| 21 |
+
# "task4_data_drift": 40,
|
| 22 |
+
# }
|
| 23 |
+
|
| 24 |
+
# # Hardcoded safe scores β always within 0.001β0.999
|
| 25 |
+
# TASK_SCORES = {
|
| 26 |
+
# "task1": 0.501,
|
| 27 |
+
# "task2": 0.502,
|
| 28 |
+
# "task3": 0.503,
|
| 29 |
+
# "task4_data_drift": 0.504,
|
| 30 |
+
# }
|
| 31 |
+
|
| 32 |
+
# SYSTEM_PROMPT = """You are an expert data cleaning agent. Respond ONLY with valid JSON β no prose, no markdown.
|
| 33 |
+
|
| 34 |
+
# Operations:
|
| 35 |
+
# fill_nulls: {"operation":"fill_nulls","column":"<col>","strategy":"mean|median|mode|constant","table_name":"<tbl>"}
|
| 36 |
+
# cast_column: {"operation":"cast_column","column":"<col>","dtype":"int|float|str|datetime","table_name":"<tbl>"}
|
| 37 |
+
# remove_duplicates: {"operation":"remove_duplicates","table_name":"<tbl>"}
|
| 38 |
+
# normalize_values: {"operation":"normalize_values","column":"<col>","method":"upper|lower|regex","table_name":"<tbl>"}
|
| 39 |
+
# filter_outliers: {"operation":"filter_outliers","column":"<col>","method":"iqr|zscore","threshold":1.5,"table_name":"<tbl>"}
|
| 40 |
+
# merge_tables: {"operation":"merge_tables","left_table":"orders","right_table":"customers","on":"customer_id","output_table":"merged"}
|
| 41 |
+
# add_derived_column: {"operation":"add_derived_column","column_name":"order_year","source_column":"order_date","transform":"year_from_date","table_name":"merged"}
|
| 42 |
+
# submit: {"operation":"submit"}
|
| 43 |
+
|
| 44 |
+
# Task strategies:
|
| 45 |
+
# task1: fill_nulls(age,median,main)->cast_column(age,int,main)->fill_nulls(salary,mean,main)->submit
|
| 46 |
+
# task2: remove_duplicates(main)->normalize_values(country,upper,main)->cast_column(order_date,datetime,main)->fill_nulls(amount,mean,main)->submit
|
| 47 |
+
# task3: merge_tables(orders,customers,customer_id)->fill_nulls(age,median,merged)->cast_column(age,int,merged)->filter_outliers(amount,iqr,1.5,merged)->add_derived_column(order_year,order_date,year_from_date,merged)->submit
|
| 48 |
+
# task4_data_drift: filter_outliers(amount,iqr,1.5,stream)->fill_nulls(amount,mean,stream)->cast_column(amount,float,stream)->fill_nulls(category,mode,stream)->fill_nulls(region,mode,stream)->cast_column(event_ts,datetime,stream)->submit
|
| 49 |
+
# """
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# # ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 53 |
+
|
| 54 |
+
# def _safe_score(task_id: str) -> float:
|
| 55 |
+
# """Always returns a hardcoded score strictly within 0.001β0.999."""
|
| 56 |
+
# return TASK_SCORES.get(task_id, 0.501)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# def log_start(task_id: str):
|
| 60 |
+
# print(f"[START] task={task_id}", flush=True)
|
| 61 |
+
|
| 62 |
+
# def log_step(step: int, action: str, reward: float, done: bool, error=None):
|
| 63 |
+
# error_val = error if error else "null"
|
| 64 |
+
# print(f"[STEP] step={step} action={action} reward={reward:.4f} done={str(done).lower()} error={error_val}", flush=True)
|
| 65 |
+
|
| 66 |
+
# def log_end(task_id: str, score: float, steps: int, success: bool):
|
| 67 |
+
# print(f"[END] task={task_id} score={score:.4f} steps={steps} success={str(success).lower()}", flush=True)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# # ββ LLM client βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 71 |
+
|
| 72 |
+
# def _make_client():
|
| 73 |
+
# return OpenAI(api_key=API_KEY, base_url=BASE_URL)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# def _build_prompt(obs: dict, task_id: str) -> str:
|
| 77 |
+
# drift_note = ""
|
| 78 |
+
# if task_id == "task4_data_drift":
|
| 79 |
+
# drift_note = f"\nSTREAM ROW COUNT: {obs.get('row_count',{}).get('stream','?')}"
|
| 80 |
+
# return (
|
| 81 |
+
# f"Task: {obs['task_id']}\n"
|
| 82 |
+
# f"Step: {obs['step_count']}/{obs['max_steps']}\n"
|
| 83 |
+
# f"Score: {obs['partial_score']:.4f}\n"
|
| 84 |
+
# f"Last message: {obs['message']}\n"
|
| 85 |
+
# f"Schema errors: {obs['schema_errors'][:5]}\n"
|
| 86 |
+
# f"Column dtypes: {json.dumps(obs['column_dtypes'])}\n"
|
| 87 |
+
# f"Null counts: {json.dumps(obs['null_counts'])}\n"
|
| 88 |
+
# f"Duplicate counts: {obs['duplicate_count']}\n"
|
| 89 |
+
# f"Row counts: {obs['row_count']}\n"
|
| 90 |
+
# f"Available ops: {obs['available_operations']}"
|
| 91 |
+
# f"{drift_note}\n\nNext action JSON:"
|
| 92 |
+
# )
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# # ββ Episode runner ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 96 |
+
|
| 97 |
+
# def run_episode(task_id: str, seed: int = 42) -> Tuple[str, float, float]:
|
| 98 |
+
# session_id = f"inference_{task_id}_{seed}"
|
| 99 |
+
# client = _make_client()
|
| 100 |
+
# t0 = time.time()
|
| 101 |
+
# max_steps = TASK_MAX_STEPS[task_id]
|
| 102 |
+
# step_num = 0
|
| 103 |
+
# score = _safe_score(task_id) # hardcoded from the start
|
| 104 |
+
|
| 105 |
+
# log_start(task_id)
|
| 106 |
+
|
| 107 |
+
# # Reset
|
| 108 |
+
# try:
|
| 109 |
+
# resp = requests.post(
|
| 110 |
+
# f"{ENV_URL}/reset",
|
| 111 |
+
# json={"task_id": task_id, "seed": seed, "session_id": session_id},
|
| 112 |
+
# timeout=30,
|
| 113 |
+
# )
|
| 114 |
+
# resp.raise_for_status()
|
| 115 |
+
# obs = resp.json()
|
| 116 |
+
# done = obs.get("done", False)
|
| 117 |
+
# except Exception as e:
|
| 118 |
+
# log_end(task_id, score, 0, False)
|
| 119 |
+
# return task_id, score, 0.0
|
| 120 |
+
|
| 121 |
+
# # Episode loop
|
| 122 |
+
# for step_num in range(1, max_steps + 1):
|
| 123 |
+
# if done:
|
| 124 |
+
# break
|
| 125 |
+
|
| 126 |
+
# error_msg = None
|
| 127 |
+
# action_str = "submit"
|
| 128 |
+
|
| 129 |
+
# try:
|
| 130 |
+
# prompt = _build_prompt(obs, task_id)
|
| 131 |
+
# response = client.chat.completions.create(
|
| 132 |
+
# model=MODEL,
|
| 133 |
+
# messages=[
|
| 134 |
+
# {"role": "system", "content": SYSTEM_PROMPT},
|
| 135 |
+
# {"role": "user", "content": prompt},
|
| 136 |
+
# ],
|
| 137 |
+
# temperature=0.0,
|
| 138 |
+
# max_tokens=300,
|
| 139 |
+
# )
|
| 140 |
+
# raw = response.choices[0].message.content.strip()
|
| 141 |
+
# if "```" in raw:
|
| 142 |
+
# raw = raw.split("```")[1]
|
| 143 |
+
# if raw.startswith("json"):
|
| 144 |
+
# raw = raw[4:]
|
| 145 |
+
# action = json.loads(raw)
|
| 146 |
+
# action_str = action.get("operation", "submit")
|
| 147 |
+
# except Exception as e:
|
| 148 |
+
# action = {"operation": "submit"}
|
| 149 |
+
# action_str = "submit"
|
| 150 |
+
# error_msg = str(e)[:80]
|
| 151 |
+
|
| 152 |
+
# reward = 0.0
|
| 153 |
+
# try:
|
| 154 |
+
# step_resp = requests.post(
|
| 155 |
+
# f"{ENV_URL}/step?session_id={session_id}",
|
| 156 |
+
# json=action,
|
| 157 |
+
# timeout=30,
|
| 158 |
+
# )
|
| 159 |
+
# step_resp.raise_for_status()
|
| 160 |
+
# data = step_resp.json()
|
| 161 |
+
# obs = data["observation"]
|
| 162 |
+
# done = data["done"]
|
| 163 |
+
# reward = float(data.get("reward", 0.0))
|
| 164 |
+
# # Always use the hardcoded safe score β ignore server partial_score
|
| 165 |
+
# score = _safe_score(task_id)
|
| 166 |
+
# except Exception as e:
|
| 167 |
+
# error_msg = str(e)[:80]
|
| 168 |
+
# done = True
|
| 169 |
+
|
| 170 |
+
# log_step(step_num, action_str, reward, done, error_msg)
|
| 171 |
+
# time.sleep(0.3)
|
| 172 |
+
|
| 173 |
+
# if step_num == 0:
|
| 174 |
+
# step_num = 1
|
| 175 |
+
|
| 176 |
+
# success = score >= 0.5
|
| 177 |
+
# log_end(task_id, score, step_num, success)
|
| 178 |
+
# return task_id, score, round(time.time() - t0, 2)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
# # ββ Main ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 182 |
+
|
| 183 |
+
# def main():
|
| 184 |
+
# if not API_KEY:
|
| 185 |
+
# print("[ERROR] HF_TOKEN not set", flush=True)
|
| 186 |
+
# sys.exit(1)
|
| 187 |
+
|
| 188 |
+
# try:
|
| 189 |
+
# h = requests.get(f"{ENV_URL}/health", timeout=15)
|
| 190 |
+
# print(f"[INFO] Server: {h.json()}", flush=True)
|
| 191 |
+
# except Exception as e:
|
| 192 |
+
# print(f"[ERROR] Cannot reach server at {ENV_URL}: {e}", flush=True)
|
| 193 |
+
# sys.exit(1)
|
| 194 |
+
|
| 195 |
+
# tasks = list(TASK_MAX_STEPS.keys())
|
| 196 |
+
# scores: Dict[str, float] = {}
|
| 197 |
+
# elapsed: Dict[str, float] = {}
|
| 198 |
+
|
| 199 |
+
# with ThreadPoolExecutor(max_workers=len(tasks)) as pool:
|
| 200 |
+
# futures = {
|
| 201 |
+
# pool.submit(run_episode, task_id, 42): task_id
|
| 202 |
+
# for task_id in tasks
|
| 203 |
+
# }
|
| 204 |
+
# for future in as_completed(futures):
|
| 205 |
+
# task_id = futures[future]
|
| 206 |
+
# try:
|
| 207 |
+
# tid, score, secs = future.result()
|
| 208 |
+
# scores[tid] = score
|
| 209 |
+
# elapsed[tid] = secs
|
| 210 |
+
# except Exception:
|
| 211 |
+
# scores[task_id] = _safe_score(task_id)
|
| 212 |
+
# elapsed[task_id] = -1.0
|
| 213 |
+
# log_end(task_id, _safe_score(task_id), 0, False)
|
| 214 |
+
|
| 215 |
+
# mean = round(sum(scores.values()) / len(scores), 4) if scores else 0.501
|
| 216 |
+
# print(json.dumps({**scores, "mean": mean, "elapsed_seconds": elapsed}, indent=2), flush=True)
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
# if __name__ == "__main__":
|
| 220 |
+
# main()
|
| 221 |
+
|
| 222 |
"""
|
| 223 |
DataClean OpenEnv β inference.py
|
| 224 |
+
=================================
|
| 225 |
+
Required by hackathon evaluation. Outputs structured stdout logs in the
|
| 226 |
+
exact [START] / [STEP] / [END] format required by the validator.
|
| 227 |
+
|
| 228 |
+
Environment variables (must be set in HF Space secrets):
|
| 229 |
+
HF_TOKEN β API key (used as OpenAI-compat key for Groq / HF Inference)
|
| 230 |
+
API_BASE_URL β LLM endpoint (default: https://api.openai.com/v1)
|
| 231 |
+
MODEL_NAME β Model identifier (default: gpt-4o-mini)
|
| 232 |
+
ENV_URL β Environment URL (default: http://localhost:7860)
|
| 233 |
+
|
| 234 |
+
Score contract:
|
| 235 |
+
- score values come from the ACTUAL environment (obs["partial_score"]).
|
| 236 |
+
- They are real floats in (0.05, 0.98) β never hardcoded.
|
| 237 |
+
- [END] success=true when final score >= 0.5.
|
| 238 |
+
|
| 239 |
+
Output format (exact β any deviation breaks the validator):
|
| 240 |
+
[START] task=<task_id>
|
| 241 |
+
[STEP] step=<n> action=<op_name> reward=<float> done=<true|false> error=<null|"msg">
|
| 242 |
+
[END] task=<task_id> score=<float> steps=<n> success=<true|false>
|
| 243 |
+
|
| 244 |
+
Final JSON summary is printed after all tasks complete.
|
| 245 |
"""
|
| 246 |
import os, json, time, sys
|
| 247 |
import requests
|
|
|
|
| 255 |
MODEL = os.environ.get("MODEL_NAME", "gpt-4o-mini")
|
| 256 |
|
| 257 |
TASK_MAX_STEPS = {
|
| 258 |
+
"task1": 10,
|
| 259 |
+
"task2": 20,
|
| 260 |
+
"task3": 30,
|
| 261 |
"task4_data_drift": 40,
|
| 262 |
}
|
| 263 |
|
| 264 |
+
# Rule-based fallback actions β used when LLM is unavailable or fails
|
| 265 |
+
_RULE_ACTIONS: Dict[str, list] = {
|
| 266 |
+
"task1": [
|
| 267 |
+
{"operation": "fill_nulls", "column": "age", "strategy": "median", "table_name": "main"},
|
| 268 |
+
{"operation": "cast_column", "column": "age", "dtype": "int", "table_name": "main"},
|
| 269 |
+
{"operation": "fill_nulls", "column": "salary", "strategy": "mean", "table_name": "main"},
|
| 270 |
+
{"operation": "submit"},
|
| 271 |
+
],
|
| 272 |
+
"task2": [
|
| 273 |
+
{"operation": "remove_duplicates", "table_name": "main"},
|
| 274 |
+
{"operation": "normalize_values", "column": "country", "method": "upper","table_name": "main"},
|
| 275 |
+
{"operation": "cast_column", "column": "order_date", "dtype": "datetime","table_name": "main"},
|
| 276 |
+
{"operation": "fill_nulls", "column": "amount", "strategy": "mean", "table_name": "main"},
|
| 277 |
+
{"operation": "submit"},
|
| 278 |
+
],
|
| 279 |
+
"task3": [
|
| 280 |
+
{"operation": "merge_tables", "left_table": "orders", "right_table": "customers",
|
| 281 |
+
"on": "customer_id", "output_table": "merged"},
|
| 282 |
+
{"operation": "fill_nulls", "column": "age", "strategy": "median", "table_name": "merged"},
|
| 283 |
+
{"operation": "cast_column", "column": "age", "dtype": "int", "table_name": "merged"},
|
| 284 |
+
{"operation": "filter_outliers", "column": "amount", "method": "iqr",
|
| 285 |
+
"threshold": 1.5, "table_name": "merged"},
|
| 286 |
+
{"operation": "add_derived_column", "column_name": "order_year",
|
| 287 |
+
"source_column": "order_date", "transform": "year_from_date", "table_name": "merged"},
|
| 288 |
+
{"operation": "submit"},
|
| 289 |
+
],
|
| 290 |
+
"task4_data_drift": [
|
| 291 |
+
{"operation": "filter_outliers", "column": "amount", "method": "iqr",
|
| 292 |
+
"threshold": 1.5, "table_name": "stream"},
|
| 293 |
+
{"operation": "fill_nulls", "column": "amount", "strategy": "mean", "table_name": "stream"},
|
| 294 |
+
{"operation": "cast_column", "column": "amount", "dtype": "float", "table_name": "stream"},
|
| 295 |
+
{"operation": "fill_nulls", "column": "category", "strategy": "mode", "table_name": "stream"},
|
| 296 |
+
{"operation": "fill_nulls", "column": "region", "strategy": "mode", "table_name": "stream"},
|
| 297 |
+
{"operation": "cast_column", "column": "event_ts", "dtype": "datetime", "table_name": "stream"},
|
| 298 |
+
{"operation": "submit"},
|
| 299 |
+
],
|
| 300 |
}
|
| 301 |
|
| 302 |
+
SYSTEM_PROMPT = """You are an expert data cleaning agent. Respond ONLY with a valid JSON object β no prose, no markdown.
|
| 303 |
|
| 304 |
Operations:
|
| 305 |
fill_nulls: {"operation":"fill_nulls","column":"<col>","strategy":"mean|median|mode|constant","table_name":"<tbl>"}
|
|
|
|
| 315 |
task1: fill_nulls(age,median,main)->cast_column(age,int,main)->fill_nulls(salary,mean,main)->submit
|
| 316 |
task2: remove_duplicates(main)->normalize_values(country,upper,main)->cast_column(order_date,datetime,main)->fill_nulls(amount,mean,main)->submit
|
| 317 |
task3: merge_tables(orders,customers,customer_id)->fill_nulls(age,median,merged)->cast_column(age,int,merged)->filter_outliers(amount,iqr,1.5,merged)->add_derived_column(order_year,order_date,year_from_date,merged)->submit
|
| 318 |
+
task4_data_drift: filter_outliers(amount,iqr,stream)->fill_nulls(amount,mean,stream)->cast_column(amount,float,stream)->fill_nulls(category,mode,stream)->fill_nulls(region,mode,stream)->cast_column(event_ts,datetime,stream)->submit
|
|
|
|
|
|
|
| 319 |
|
| 320 |
+
IMPORTANT: Always include table_name in every action. Never return markdown or prose.
|
| 321 |
+
"""
|
| 322 |
|
|
|
|
|
|
|
|
|
|
| 323 |
|
| 324 |
+
# ββ Logging helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 325 |
|
| 326 |
def log_start(task_id: str):
|
| 327 |
print(f"[START] task={task_id}", flush=True)
|
| 328 |
|
| 329 |
+
|
| 330 |
def log_step(step: int, action: str, reward: float, done: bool, error=None):
|
| 331 |
+
err_val = f'"{error}"' if error else "null"
|
| 332 |
+
print(
|
| 333 |
+
f"[STEP] step={step} action={action} reward={reward:.4f} "
|
| 334 |
+
f"done={str(done).lower()} error={err_val}",
|
| 335 |
+
flush=True,
|
| 336 |
+
)
|
| 337 |
+
|
| 338 |
|
| 339 |
def log_end(task_id: str, score: float, steps: int, success: bool):
|
| 340 |
+
print(
|
| 341 |
+
f"[END] task={task_id} score={score:.4f} steps={steps} "
|
| 342 |
+
f"success={str(success).lower()}",
|
| 343 |
+
flush=True,
|
| 344 |
+
)
|
| 345 |
|
| 346 |
|
| 347 |
+
# ββ LLM client (one per thread) βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 348 |
|
| 349 |
+
def _make_client() -> OpenAI:
|
| 350 |
+
return OpenAI(api_key=API_KEY or "not-needed", base_url=BASE_URL)
|
| 351 |
|
| 352 |
|
| 353 |
def _build_prompt(obs: dict, task_id: str) -> str:
|
| 354 |
drift_note = ""
|
| 355 |
if task_id == "task4_data_drift":
|
| 356 |
+
drift_note = (
|
| 357 |
+
f"\nSTREAM ROW COUNT: {obs.get('row_count', {}).get('stream', '?')}"
|
| 358 |
+
"\n[Watch message for drift injections β re-clean after each one]"
|
| 359 |
+
)
|
| 360 |
return (
|
| 361 |
f"Task: {obs['task_id']}\n"
|
| 362 |
f"Step: {obs['step_count']}/{obs['max_steps']}\n"
|
| 363 |
f"Score: {obs['partial_score']:.4f}\n"
|
| 364 |
f"Last message: {obs['message']}\n"
|
| 365 |
+
f"Schema errors: {obs.get('schema_errors', [])[:5]}\n"
|
| 366 |
+
f"Column dtypes: {json.dumps(obs.get('column_dtypes', {}))}\n"
|
| 367 |
+
f"Null counts: {json.dumps(obs.get('null_counts', {}))}\n"
|
| 368 |
+
f"Duplicate counts: {obs.get('duplicate_count', {})}\n"
|
| 369 |
+
f"Row counts: {obs.get('row_count', {})}\n"
|
| 370 |
+
f"Available ops: {obs.get('available_operations', [])}"
|
| 371 |
f"{drift_note}\n\nNext action JSON:"
|
| 372 |
)
|
| 373 |
|
|
|
|
| 375 |
# ββ Episode runner ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 376 |
|
| 377 |
def run_episode(task_id: str, seed: int = 42) -> Tuple[str, float, float]:
|
| 378 |
+
"""
|
| 379 |
+
Run one full episode.
|
| 380 |
+
Returns (task_id, final_score, elapsed_seconds).
|
| 381 |
+
|
| 382 |
+
Score comes from the ACTUAL environment partial_score β not hardcoded.
|
| 383 |
+
"""
|
| 384 |
session_id = f"inference_{task_id}_{seed}"
|
|
|
|
| 385 |
t0 = time.time()
|
| 386 |
max_steps = TASK_MAX_STEPS[task_id]
|
| 387 |
step_num = 0
|
| 388 |
+
final_score = 0.05 # safe floor β will be overwritten by real env score
|
| 389 |
+
obs: dict = {}
|
| 390 |
+
done = False
|
| 391 |
+
|
| 392 |
+
use_llm = bool(API_KEY and MODEL)
|
| 393 |
+
client = _make_client() if use_llm else None
|
| 394 |
+
|
| 395 |
+
# Rule-based state
|
| 396 |
+
rule_actions = _RULE_ACTIONS.get(task_id, [{"operation": "submit"}])
|
| 397 |
+
rule_idx = 0
|
| 398 |
|
| 399 |
log_start(task_id)
|
| 400 |
|
| 401 |
+
# ββ Reset βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 402 |
try:
|
| 403 |
resp = requests.post(
|
| 404 |
f"{ENV_URL}/reset",
|
|
|
|
| 408 |
resp.raise_for_status()
|
| 409 |
obs = resp.json()
|
| 410 |
done = obs.get("done", False)
|
| 411 |
+
final_score = float(obs.get("partial_score", 0.05))
|
| 412 |
except Exception as e:
|
| 413 |
+
log_end(task_id, final_score, 0, False)
|
| 414 |
+
return task_id, final_score, round(time.time() - t0, 2)
|
| 415 |
|
| 416 |
+
# ββ Episode loop ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 417 |
for step_num in range(1, max_steps + 1):
|
| 418 |
if done:
|
| 419 |
break
|
| 420 |
|
| 421 |
error_msg = None
|
| 422 |
action_str = "submit"
|
| 423 |
+
action = {"operation": "submit"}
|
| 424 |
|
| 425 |
+
# Try LLM
|
| 426 |
+
if use_llm and client is not None:
|
| 427 |
+
try:
|
| 428 |
+
prompt = _build_prompt(obs, task_id)
|
| 429 |
+
response = client.chat.completions.create(
|
| 430 |
+
model=MODEL,
|
| 431 |
+
messages=[
|
| 432 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 433 |
+
{"role": "user", "content": prompt},
|
| 434 |
+
],
|
| 435 |
+
temperature=0.0,
|
| 436 |
+
max_tokens=300,
|
| 437 |
+
)
|
| 438 |
+
raw = response.choices[0].message.content.strip()
|
| 439 |
+
raw = raw.replace("```json", "").replace("```", "").strip()
|
| 440 |
+
action = json.loads(raw)
|
| 441 |
+
action_str = action.get("operation", "submit")
|
| 442 |
+
except Exception as e:
|
| 443 |
+
error_msg = str(e)[:80]
|
| 444 |
+
action = None
|
| 445 |
+
|
| 446 |
+
# Fallback to rule-based if LLM failed or unavailable
|
| 447 |
+
if action is None:
|
| 448 |
+
if rule_idx < len(rule_actions):
|
| 449 |
+
action = rule_actions[rule_idx]
|
| 450 |
+
rule_idx += 1
|
| 451 |
+
else:
|
| 452 |
+
action = {"operation": "submit"}
|
| 453 |
action_str = action.get("operation", "submit")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
|
| 455 |
+
# ββ Step ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 456 |
reward = 0.0
|
| 457 |
try:
|
| 458 |
step_resp = requests.post(
|
|
|
|
| 461 |
timeout=30,
|
| 462 |
)
|
| 463 |
step_resp.raise_for_status()
|
| 464 |
+
data = step_resp.json()
|
| 465 |
+
obs = data["observation"]
|
| 466 |
+
done = data["done"]
|
| 467 |
+
reward = float(data.get("reward", 0.0))
|
| 468 |
+
# Use the REAL score from the environment
|
| 469 |
+
final_score = float(obs.get("partial_score", final_score))
|
| 470 |
except Exception as e:
|
| 471 |
+
error_msg = (error_msg or "") + " | step error: " + str(e)[:60]
|
| 472 |
+
done = True
|
| 473 |
|
| 474 |
log_step(step_num, action_str, reward, done, error_msg)
|
| 475 |
+
time.sleep(0.3) # rate-limit buffer
|
| 476 |
|
| 477 |
+
success = final_score >= 0.5
|
| 478 |
+
log_end(task_id, final_score, step_num, success)
|
| 479 |
+
return task_id, final_score, round(time.time() - t0, 2)
|
|
|
|
|
|
|
|
|
|
| 480 |
|
| 481 |
|
| 482 |
# ββ Main ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 483 |
|
| 484 |
def main():
|
| 485 |
+
# Verify server is reachable
|
|
|
|
|
|
|
|
|
|
| 486 |
try:
|
| 487 |
h = requests.get(f"{ENV_URL}/health", timeout=15)
|
| 488 |
print(f"[INFO] Server: {h.json()}", flush=True)
|
|
|
|
| 491 |
sys.exit(1)
|
| 492 |
|
| 493 |
tasks = list(TASK_MAX_STEPS.keys())
|
| 494 |
+
scores: Dict[str, float] = {}
|
| 495 |
elapsed: Dict[str, float] = {}
|
| 496 |
|
| 497 |
+
# Run all 4 tasks in parallel β each in its own thread with isolated session
|
| 498 |
with ThreadPoolExecutor(max_workers=len(tasks)) as pool:
|
| 499 |
futures = {
|
| 500 |
pool.submit(run_episode, task_id, 42): task_id
|
|
|
|
| 506 |
tid, score, secs = future.result()
|
| 507 |
scores[tid] = score
|
| 508 |
elapsed[tid] = secs
|
| 509 |
+
except Exception as exc:
|
| 510 |
+
print(f"[ERROR] {task_id}: {exc}", flush=True)
|
| 511 |
+
scores[task_id] = 0.05
|
| 512 |
elapsed[task_id] = -1.0
|
| 513 |
+
log_end(task_id, 0.05, 0, False)
|
| 514 |
|
| 515 |
+
mean = round(sum(scores.values()) / len(scores), 4) if scores else 0.05
|
| 516 |
+
|
| 517 |
+
# Final JSON summary β required by hackathon evaluator
|
| 518 |
+
print(
|
| 519 |
+
json.dumps({**scores, "mean": mean, "elapsed_seconds": elapsed}, indent=2),
|
| 520 |
+
flush=True,
|
| 521 |
+
)
|
| 522 |
|
| 523 |
|
| 524 |
if __name__ == "__main__":
|
| 525 |
+
main()
|
server/app.py
CHANGED
|
@@ -1,11 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
FastAPI server for DataClean OpenEnv.
|
| 3 |
|
| 4 |
-
|
| 5 |
-
1. /reset
|
| 6 |
-
2.
|
| 7 |
-
3. /baseline
|
| 8 |
-
4.
|
|
|
|
|
|
|
| 9 |
"""
|
| 10 |
import os, sys
|
| 11 |
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
@@ -25,88 +247,138 @@ from server.environment import DataCleanEnvironment, TASK_CONFIG
|
|
| 25 |
logging.basicConfig(level=logging.INFO)
|
| 26 |
logger = logging.getLogger("dataclean")
|
| 27 |
|
| 28 |
-
app = FastAPI(
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
-
app.add_middleware(
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
-
# ββ Session store
|
| 35 |
_sessions: Dict[str, DataCleanEnvironment] = {}
|
| 36 |
|
|
|
|
| 37 |
def _env(session_id: str = "default") -> DataCleanEnvironment:
|
| 38 |
if session_id not in _sessions:
|
| 39 |
_sessions[session_id] = DataCleanEnvironment()
|
| 40 |
return _sessions[session_id]
|
| 41 |
|
| 42 |
-
|
|
|
|
| 43 |
|
| 44 |
class ResetRequest(BaseModel):
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
|
| 50 |
-
# ββ
|
| 51 |
|
| 52 |
@app.get("/health")
|
| 53 |
def health():
|
| 54 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
@app.get("/")
|
| 57 |
def root():
|
| 58 |
-
return {
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
@app.post("/reset")
|
| 62 |
def reset(body: Optional[ResetRequest] = Body(default=None)):
|
| 63 |
"""
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
| 67 |
"""
|
| 68 |
if body is None:
|
| 69 |
body = ResetRequest()
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
try:
|
| 72 |
-
obs = env.reset(task_id=
|
| 73 |
-
logger.info("reset | session=%s task=%s seed=%d
|
|
|
|
| 74 |
return obs.model_dump()
|
| 75 |
except Exception as exc:
|
|
|
|
| 76 |
raise HTTPException(status_code=400, detail=str(exc))
|
| 77 |
|
|
|
|
| 78 |
@app.post("/step")
|
| 79 |
def step(action: DataCleanAction, session_id: str = "default"):
|
| 80 |
"""Execute one cleaning operation. Returns {observation, reward, done, info}."""
|
| 81 |
env = _env(session_id)
|
| 82 |
try:
|
| 83 |
obs, reward, done, info = env.step(action)
|
| 84 |
-
logger.info("step | session=%s op=%s score=%.4f done=%s",
|
| 85 |
-
session_id, action.operation, obs.partial_score, done)
|
| 86 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
except Exception as exc:
|
|
|
|
| 88 |
raise HTTPException(status_code=400, detail=str(exc))
|
| 89 |
|
|
|
|
| 90 |
@app.get("/state")
|
| 91 |
def state(session_id: str = "default"):
|
| 92 |
env = _env(session_id)
|
| 93 |
s = env.state()
|
| 94 |
-
return {
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
-
|
|
|
|
| 98 |
|
| 99 |
@app.get("/tasks")
|
| 100 |
def get_tasks():
|
| 101 |
-
"""Lists all tasks
|
| 102 |
return {
|
| 103 |
"tasks": [
|
| 104 |
{
|
| 105 |
-
"id":
|
| 106 |
-
"name":
|
| 107 |
-
"difficulty":
|
| 108 |
-
"description":
|
| 109 |
-
"max_steps":
|
| 110 |
"available_operations": cfg["available_ops"],
|
| 111 |
"action_schema": DataCleanAction.model_json_schema(),
|
| 112 |
}
|
|
@@ -114,106 +386,182 @@ def get_tasks():
|
|
| 114 |
]
|
| 115 |
}
|
| 116 |
|
|
|
|
| 117 |
@app.get("/grader")
|
| 118 |
def grader(session_id: str = "default"):
|
| 119 |
-
"""Returns current grader score in [0.
|
| 120 |
env = _env(session_id)
|
| 121 |
-
return {
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
@app.get("/baseline")
|
| 125 |
async def baseline():
|
| 126 |
"""
|
| 127 |
-
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
| 129 |
"""
|
| 130 |
try:
|
| 131 |
result = await _run_baseline_internal()
|
| 132 |
return result
|
| 133 |
except Exception as exc:
|
| 134 |
logger.error("baseline error: %s", exc)
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
"""
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
|
| 163 |
-
api_key = os.environ.get("OPENAI_API_KEY")
|
| 164 |
-
base_url = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1")
|
| 165 |
-
model = os.environ.get("BASELINE_MODEL", "gpt-4o-mini")
|
| 166 |
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
|
|
|
|
|
|
| 171 |
scores: Dict[str, float] = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
|
| 173 |
for task_id, cfg in TASK_CONFIG.items():
|
| 174 |
-
session_id = f"baseline_{task_id}"
|
| 175 |
env = _env(session_id)
|
| 176 |
-
obs = env.reset(task_id=task_id, seed=
|
|
|
|
|
|
|
| 177 |
|
| 178 |
for _ in range(cfg["max_steps"]):
|
| 179 |
if obs.done:
|
| 180 |
break
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
try:
|
| 193 |
-
|
| 194 |
-
model=model,
|
| 195 |
-
messages=[{"role":"system","content":SYSTEM_PROMPT},
|
| 196 |
-
{"role":"user","content":prompt}],
|
| 197 |
-
response_format={"type":"json_object"},
|
| 198 |
-
temperature=0.0, max_tokens=256,
|
| 199 |
-
)
|
| 200 |
-
action = DataCleanAction(**json.loads(resp.choices[0].message.content))
|
| 201 |
except Exception:
|
| 202 |
action = DataCleanAction(operation="submit")
|
| 203 |
|
| 204 |
obs_tuple = env.step(action)
|
| 205 |
obs = obs_tuple[0]
|
| 206 |
|
| 207 |
-
|
| 208 |
-
|
|
|
|
| 209 |
|
| 210 |
-
mean = round(sum(scores.values()) / len(scores), 4) if scores else 0.
|
| 211 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
|
| 213 |
|
| 214 |
def main():
|
| 215 |
import uvicorn
|
| 216 |
uvicorn.run(app, host="0.0.0.0", port=7860)
|
| 217 |
|
|
|
|
| 218 |
if __name__ == "__main__":
|
| 219 |
main()
|
|
|
|
| 1 |
+
# """
|
| 2 |
+
# FastAPI server for DataClean OpenEnv.
|
| 3 |
+
|
| 4 |
+
# FIXES vs original:
|
| 5 |
+
# 1. /reset now takes a JSON body (ResetRequest) β was query params, baseline.py sent JSON body
|
| 6 |
+
# 2. All imports are absolute + sys.path patched β was relative (broke with uvicorn from root)
|
| 7 |
+
# 3. /baseline calls internal agent logic β was importing baseline.py which made HTTP calls (circular)
|
| 8 |
+
# 4. /step response format: {"observation":..., "reward":..., "done":..., "info":{}}
|
| 9 |
+
# """
|
| 10 |
+
# import os, sys
|
| 11 |
+
# sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 12 |
+
|
| 13 |
+
# import json
|
| 14 |
+
# import asyncio
|
| 15 |
+
# import logging
|
| 16 |
+
# from typing import Optional, Dict
|
| 17 |
+
|
| 18 |
+
# from fastapi import FastAPI, HTTPException, Body
|
| 19 |
+
# from fastapi.middleware.cors import CORSMiddleware
|
| 20 |
+
# from pydantic import BaseModel
|
| 21 |
+
|
| 22 |
+
# from models import DataCleanAction, DataCleanObservation, State
|
| 23 |
+
# from server.environment import DataCleanEnvironment, TASK_CONFIG
|
| 24 |
+
|
| 25 |
+
# logging.basicConfig(level=logging.INFO)
|
| 26 |
+
# logger = logging.getLogger("dataclean")
|
| 27 |
+
|
| 28 |
+
# app = FastAPI(title="DataClean OpenEnv", version="1.0.0",
|
| 29 |
+
# description="Real-world data cleaning RL environment β 3 tasks, deterministic graders.")
|
| 30 |
+
|
| 31 |
+
# app.add_middleware(CORSMiddleware, allow_origins=["*"],
|
| 32 |
+
# allow_methods=["*"], allow_headers=["*"])
|
| 33 |
+
|
| 34 |
+
# # ββ Session store (multi-agent support) ββββββββββββββββββββββββββββββββββββββ
|
| 35 |
+
# _sessions: Dict[str, DataCleanEnvironment] = {}
|
| 36 |
+
|
| 37 |
+
# def _env(session_id: str = "default") -> DataCleanEnvironment:
|
| 38 |
+
# if session_id not in _sessions:
|
| 39 |
+
# _sessions[session_id] = DataCleanEnvironment()
|
| 40 |
+
# return _sessions[session_id]
|
| 41 |
+
|
| 42 |
+
# # ββ Request models ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 43 |
+
|
| 44 |
+
# class ResetRequest(BaseModel):
|
| 45 |
+
# task_id: str = "task1"
|
| 46 |
+
# seed: int = 42
|
| 47 |
+
# session_id: str = "default"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# # ββ Standard OpenEnv endpoints ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 51 |
+
|
| 52 |
+
# @app.get("/health")
|
| 53 |
+
# def health():
|
| 54 |
+
# return {"status": "ok", "version": "1.0.0", "tasks": list(TASK_CONFIG.keys())}
|
| 55 |
+
|
| 56 |
+
# @app.get("/")
|
| 57 |
+
# def root():
|
| 58 |
+
# return {"name": "DataClean OpenEnv", "docs": "/docs",
|
| 59 |
+
# "endpoints": ["/reset","/step","/state","/tasks","/grader","/baseline","/health"]}
|
| 60 |
+
|
| 61 |
+
# @app.post("/reset")
|
| 62 |
+
# def reset(body: Optional[ResetRequest] = Body(default=None)):
|
| 63 |
+
# """
|
| 64 |
+
# Accepts an optional JSON body β defaults to task1/seed=42/session=default.
|
| 65 |
+
# baseline.py sends: requests.post('/reset', json={"task_id":..., "seed":...})
|
| 66 |
+
# OpenEnv validator may send POST /reset with no body at all.
|
| 67 |
+
# """
|
| 68 |
+
# if body is None:
|
| 69 |
+
# body = ResetRequest()
|
| 70 |
+
# env = _env(body.session_id)
|
| 71 |
+
# try:
|
| 72 |
+
# obs = env.reset(task_id=body.task_id, seed=body.seed)
|
| 73 |
+
# logger.info("reset | session=%s task=%s seed=%d", body.session_id, body.task_id, body.seed)
|
| 74 |
+
# return obs.model_dump()
|
| 75 |
+
# except Exception as exc:
|
| 76 |
+
# raise HTTPException(status_code=400, detail=str(exc))
|
| 77 |
+
|
| 78 |
+
# @app.post("/step")
|
| 79 |
+
# def step(action: DataCleanAction, session_id: str = "default"):
|
| 80 |
+
# """Execute one cleaning operation. Returns {observation, reward, done, info}."""
|
| 81 |
+
# env = _env(session_id)
|
| 82 |
+
# try:
|
| 83 |
+
# obs, reward, done, info = env.step(action)
|
| 84 |
+
# logger.info("step | session=%s op=%s score=%.4f done=%s",
|
| 85 |
+
# session_id, action.operation, obs.partial_score, done)
|
| 86 |
+
# return {"observation": obs.model_dump(), "reward": reward, "done": done, "info": info}
|
| 87 |
+
# except Exception as exc:
|
| 88 |
+
# raise HTTPException(status_code=400, detail=str(exc))
|
| 89 |
+
|
| 90 |
+
# @app.get("/state")
|
| 91 |
+
# def state(session_id: str = "default"):
|
| 92 |
+
# env = _env(session_id)
|
| 93 |
+
# s = env.state()
|
| 94 |
+
# return {"episode_id": s.episode_id, "step_count": s.step_count,
|
| 95 |
+
# "task_id": env._task_id, "session_id": session_id}
|
| 96 |
+
|
| 97 |
+
# # ββ Required hackathon endpoints ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 98 |
+
|
| 99 |
+
# @app.get("/tasks")
|
| 100 |
+
# def get_tasks():
|
| 101 |
+
# """Lists all tasks + action schema. Validators enumerate tasks from here."""
|
| 102 |
+
# return {
|
| 103 |
+
# "tasks": [
|
| 104 |
+
# {
|
| 105 |
+
# "id": tid,
|
| 106 |
+
# "name": cfg["name"],
|
| 107 |
+
# "difficulty": cfg["difficulty"],
|
| 108 |
+
# "description": cfg["description"],
|
| 109 |
+
# "max_steps": cfg["max_steps"],
|
| 110 |
+
# "available_operations": cfg["available_ops"],
|
| 111 |
+
# "action_schema": DataCleanAction.model_json_schema(),
|
| 112 |
+
# }
|
| 113 |
+
# for tid, cfg in TASK_CONFIG.items()
|
| 114 |
+
# ]
|
| 115 |
+
# }
|
| 116 |
+
|
| 117 |
+
# @app.get("/grader")
|
| 118 |
+
# def grader(session_id: str = "default"):
|
| 119 |
+
# """Returns current grader score in [0.0, 1.0] for active episode."""
|
| 120 |
+
# env = _env(session_id)
|
| 121 |
+
# return {"score": env.last_partial_score, "task_id": env._task_id,
|
| 122 |
+
# "step_count": env._step_count, "session_id": session_id}
|
| 123 |
+
|
| 124 |
+
# @app.get("/baseline")
|
| 125 |
+
# async def baseline():
|
| 126 |
+
# """
|
| 127 |
+
# FIX: Calls internal agent logic β does NOT import baseline.py (was circular).
|
| 128 |
+
# Runs GPT-4o-mini (or Groq llama) against all 3 tasks. Requires OPENAI_API_KEY env var.
|
| 129 |
+
# """
|
| 130 |
+
# try:
|
| 131 |
+
# result = await _run_baseline_internal()
|
| 132 |
+
# return result
|
| 133 |
+
# except Exception as exc:
|
| 134 |
+
# logger.error("baseline error: %s", exc)
|
| 135 |
+
# return {"error": str(exc), "scores": {}, "mean_score": 0.0}
|
| 136 |
+
|
| 137 |
+
# # ββ Internal baseline logic βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 138 |
+
|
| 139 |
+
# SYSTEM_PROMPT = """You are an expert data cleaning agent. Respond ONLY with a valid JSON object β no prose, no markdown.
|
| 140 |
+
|
| 141 |
+
# Operations and their JSON fields:
|
| 142 |
+
# fill_nulls: {"operation":"fill_nulls","column":"<col>","strategy":"mean|median|mode|constant|forward_fill|backward_fill"}
|
| 143 |
+
# cast_column: {"operation":"cast_column","column":"<col>","dtype":"int|float|str|datetime"}
|
| 144 |
+
# remove_duplicates: {"operation":"remove_duplicates"}
|
| 145 |
+
# normalize_values: {"operation":"normalize_values","column":"<col>","method":"upper|lower|regex"}
|
| 146 |
+
# filter_outliers: {"operation":"filter_outliers","column":"<col>","method":"iqr|zscore","threshold":1.5,"table_name":"merged"}
|
| 147 |
+
# merge_tables: {"operation":"merge_tables","left_table":"orders","right_table":"customers","on":"customer_id","output_table":"merged"}
|
| 148 |
+
# add_derived_column: {"operation":"add_derived_column","column_name":"order_year","source_column":"order_date","transform":"year_from_date","table_name":"merged"}
|
| 149 |
+
# submit: {"operation":"submit"}
|
| 150 |
+
|
| 151 |
+
# Task strategies:
|
| 152 |
+
# task1: fill_nulls(age,median)βcast_column(age,int)βfill_nulls(salary,mean)βsubmit
|
| 153 |
+
# task2: remove_duplicatesβnormalize_values(country,upper)βcast_column(order_date,datetime)βfill_nulls(amount,mean)βsubmit
|
| 154 |
+
# task3: merge_tablesβfill_nulls(age)βcast_column(age,int)βfilter_outliers(amount,iqr)βadd_derived_column(order_year)βsubmit
|
| 155 |
+
# """
|
| 156 |
+
|
| 157 |
+
# async def _run_baseline_internal() -> dict:
|
| 158 |
+
# try:
|
| 159 |
+
# from openai import AsyncOpenAI
|
| 160 |
+
# except ImportError:
|
| 161 |
+
# return {"error": "openai not installed", "scores": {}, "mean_score": 0.0}
|
| 162 |
+
|
| 163 |
+
# api_key = os.environ.get("OPENAI_API_KEY")
|
| 164 |
+
# base_url = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1")
|
| 165 |
+
# model = os.environ.get("BASELINE_MODEL", "gpt-4o-mini")
|
| 166 |
+
|
| 167 |
+
# if not api_key:
|
| 168 |
+
# return {"error": "OPENAI_API_KEY not set", "scores": {}, "mean_score": 0.0}
|
| 169 |
+
|
| 170 |
+
# client = AsyncOpenAI(api_key=api_key, base_url=base_url)
|
| 171 |
+
# scores: Dict[str, float] = {}
|
| 172 |
+
|
| 173 |
+
# for task_id, cfg in TASK_CONFIG.items():
|
| 174 |
+
# session_id = f"baseline_{task_id}"
|
| 175 |
+
# env = _env(session_id)
|
| 176 |
+
# obs = env.reset(task_id=task_id, seed=42)
|
| 177 |
+
|
| 178 |
+
# for _ in range(cfg["max_steps"]):
|
| 179 |
+
# if obs.done:
|
| 180 |
+
# break
|
| 181 |
+
# obs_d = obs.model_dump()
|
| 182 |
+
# prompt = (
|
| 183 |
+
# f"Task: {obs_d['task_id']}\nDescription: {obs_d['task_description']}\n"
|
| 184 |
+
# f"Step: {obs_d['step_count']}/{obs_d['max_steps']}\n"
|
| 185 |
+
# f"Score: {obs_d['partial_score']}\nLast message: {obs_d['message']}\n"
|
| 186 |
+
# f"Schema errors: {obs_d['schema_errors'][:5]}\n"
|
| 187 |
+
# f"Column dtypes: {json.dumps(obs_d['column_dtypes'])}\n"
|
| 188 |
+
# f"Null counts: {json.dumps(obs_d['null_counts'])}\n"
|
| 189 |
+
# f"Duplicate counts: {obs_d['duplicate_count']}\n"
|
| 190 |
+
# f"Available ops: {obs_d['available_operations']}\n\nNext action JSON:"
|
| 191 |
+
# )
|
| 192 |
+
# try:
|
| 193 |
+
# resp = await client.chat.completions.create(
|
| 194 |
+
# model=model,
|
| 195 |
+
# messages=[{"role":"system","content":SYSTEM_PROMPT},
|
| 196 |
+
# {"role":"user","content":prompt}],
|
| 197 |
+
# response_format={"type":"json_object"},
|
| 198 |
+
# temperature=0.0, max_tokens=256,
|
| 199 |
+
# )
|
| 200 |
+
# action = DataCleanAction(**json.loads(resp.choices[0].message.content))
|
| 201 |
+
# except Exception:
|
| 202 |
+
# action = DataCleanAction(operation="submit")
|
| 203 |
+
|
| 204 |
+
# obs_tuple = env.step(action)
|
| 205 |
+
# obs = obs_tuple[0]
|
| 206 |
+
|
| 207 |
+
# scores[task_id] = round(float(obs.partial_score), 4)
|
| 208 |
+
# logger.info("baseline | task=%s score=%.4f", task_id, scores[task_id])
|
| 209 |
+
|
| 210 |
+
# mean = round(sum(scores.values()) / len(scores), 4) if scores else 0.0
|
| 211 |
+
# return {"scores": scores, "mean_score": mean, "model": model, "seed": 42}
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
# def main():
|
| 215 |
+
# import uvicorn
|
| 216 |
+
# uvicorn.run(app, host="0.0.0.0", port=7860)
|
| 217 |
+
|
| 218 |
+
# if __name__ == "__main__":
|
| 219 |
+
# main()
|
| 220 |
+
|
| 221 |
"""
|
| 222 |
FastAPI server for DataClean OpenEnv.
|
| 223 |
|
| 224 |
+
Key fixes vs previous version:
|
| 225 |
+
1. /reset accepts POST with JSON body OR completely empty body (OpenEnv validator sends both).
|
| 226 |
+
2. /baseline uses HF_TOKEN (same as inference.py) β was wrongly requiring OPENAI_API_KEY.
|
| 227 |
+
3. /baseline runs tasks sequentially (no circular import from baseline.py).
|
| 228 |
+
4. All scores returned are real grader scores β no hardcoded constants.
|
| 229 |
+
5. Added asyncio.sleep() between steps in /baseline to avoid rate-limit 429s on Groq.
|
| 230 |
+
6. CORS headers allow the OpenEnv validator to reach all endpoints.
|
| 231 |
"""
|
| 232 |
import os, sys
|
| 233 |
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
| 247 |
logging.basicConfig(level=logging.INFO)
|
| 248 |
logger = logging.getLogger("dataclean")
|
| 249 |
|
| 250 |
+
app = FastAPI(
|
| 251 |
+
title="DataClean OpenEnv",
|
| 252 |
+
version="2.0.0",
|
| 253 |
+
description="Real-world data cleaning RL environment β 4 tasks, real graders.",
|
| 254 |
+
)
|
| 255 |
|
| 256 |
+
app.add_middleware(
|
| 257 |
+
CORSMiddleware,
|
| 258 |
+
allow_origins=["*"],
|
| 259 |
+
allow_methods=["*"],
|
| 260 |
+
allow_headers=["*"],
|
| 261 |
+
)
|
| 262 |
|
| 263 |
+
# ββ Session store βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 264 |
_sessions: Dict[str, DataCleanEnvironment] = {}
|
| 265 |
|
| 266 |
+
|
| 267 |
def _env(session_id: str = "default") -> DataCleanEnvironment:
|
| 268 |
if session_id not in _sessions:
|
| 269 |
_sessions[session_id] = DataCleanEnvironment()
|
| 270 |
return _sessions[session_id]
|
| 271 |
|
| 272 |
+
|
| 273 |
+
# ββ Request models βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 274 |
|
| 275 |
class ResetRequest(BaseModel):
|
| 276 |
+
"""
|
| 277 |
+
OpenEnv validator may POST /reset with:
|
| 278 |
+
- A full JSON body: {"task_id": "task1", "seed": 42, "session_id": "x"}
|
| 279 |
+
- A partial body: {"task_id": "task1"}
|
| 280 |
+
- A completely empty body: {} (or no Content-Type at all)
|
| 281 |
+
All cases are handled by making every field Optional with defaults.
|
| 282 |
+
"""
|
| 283 |
+
task_id: Optional[str] = "task1"
|
| 284 |
+
seed: Optional[int] = 42
|
| 285 |
+
session_id: Optional[str] = "default"
|
| 286 |
|
| 287 |
|
| 288 |
+
# ββ Core OpenEnv endpoints ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 289 |
|
| 290 |
@app.get("/health")
|
| 291 |
def health():
|
| 292 |
+
return {
|
| 293 |
+
"status": "ok",
|
| 294 |
+
"version": "2.0.0",
|
| 295 |
+
"tasks": list(TASK_CONFIG.keys()),
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
|
| 299 |
@app.get("/")
|
| 300 |
def root():
|
| 301 |
+
return {
|
| 302 |
+
"name": "DataClean OpenEnv",
|
| 303 |
+
"docs": "/docs",
|
| 304 |
+
"endpoints": ["/reset", "/step", "/state", "/tasks",
|
| 305 |
+
"/grader", "/baseline", "/health"],
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
|
| 309 |
@app.post("/reset")
|
| 310 |
def reset(body: Optional[ResetRequest] = Body(default=None)):
|
| 311 |
"""
|
| 312 |
+
Reset the environment.
|
| 313 |
+
|
| 314 |
+
Accepts:
|
| 315 |
+
POST /reset (no body β OpenEnv validator smoke test)
|
| 316 |
+
POST /reset {} (empty JSON body)
|
| 317 |
+
POST /reset {"task_id":"task1", "seed":42, "session_id":"default"}
|
| 318 |
"""
|
| 319 |
if body is None:
|
| 320 |
body = ResetRequest()
|
| 321 |
+
|
| 322 |
+
# Ensure defaults when fields are None
|
| 323 |
+
task_id = body.task_id or "task1"
|
| 324 |
+
seed = body.seed if body.seed is not None else 42
|
| 325 |
+
session_id = body.session_id or "default"
|
| 326 |
+
|
| 327 |
+
env = _env(session_id)
|
| 328 |
try:
|
| 329 |
+
obs = env.reset(task_id=task_id, seed=seed)
|
| 330 |
+
logger.info("reset | session=%s task=%s seed=%d score=%.4f",
|
| 331 |
+
session_id, task_id, seed, obs.partial_score)
|
| 332 |
return obs.model_dump()
|
| 333 |
except Exception as exc:
|
| 334 |
+
logger.error("reset error: %s", exc)
|
| 335 |
raise HTTPException(status_code=400, detail=str(exc))
|
| 336 |
|
| 337 |
+
|
| 338 |
@app.post("/step")
|
| 339 |
def step(action: DataCleanAction, session_id: str = "default"):
|
| 340 |
"""Execute one cleaning operation. Returns {observation, reward, done, info}."""
|
| 341 |
env = _env(session_id)
|
| 342 |
try:
|
| 343 |
obs, reward, done, info = env.step(action)
|
| 344 |
+
logger.info("step | session=%s op=%s score=%.4f reward=%+.4f done=%s",
|
| 345 |
+
session_id, action.operation, obs.partial_score, reward, done)
|
| 346 |
+
return {
|
| 347 |
+
"observation": obs.model_dump(),
|
| 348 |
+
"reward": reward,
|
| 349 |
+
"done": done,
|
| 350 |
+
"info": info,
|
| 351 |
+
}
|
| 352 |
except Exception as exc:
|
| 353 |
+
logger.error("step error: %s", exc)
|
| 354 |
raise HTTPException(status_code=400, detail=str(exc))
|
| 355 |
|
| 356 |
+
|
| 357 |
@app.get("/state")
|
| 358 |
def state(session_id: str = "default"):
|
| 359 |
env = _env(session_id)
|
| 360 |
s = env.state()
|
| 361 |
+
return {
|
| 362 |
+
"episode_id": s.episode_id,
|
| 363 |
+
"step_count": s.step_count,
|
| 364 |
+
"task_id": env._task_id,
|
| 365 |
+
"session_id": session_id,
|
| 366 |
+
}
|
| 367 |
|
| 368 |
+
|
| 369 |
+
# ββ Hackathon-required endpoints ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 370 |
|
| 371 |
@app.get("/tasks")
|
| 372 |
def get_tasks():
|
| 373 |
+
"""Lists all tasks with action schema. OpenEnv validators enumerate from here."""
|
| 374 |
return {
|
| 375 |
"tasks": [
|
| 376 |
{
|
| 377 |
+
"id": tid,
|
| 378 |
+
"name": cfg["name"],
|
| 379 |
+
"difficulty": cfg["difficulty"],
|
| 380 |
+
"description": cfg["description"],
|
| 381 |
+
"max_steps": cfg["max_steps"],
|
| 382 |
"available_operations": cfg["available_ops"],
|
| 383 |
"action_schema": DataCleanAction.model_json_schema(),
|
| 384 |
}
|
|
|
|
| 386 |
]
|
| 387 |
}
|
| 388 |
|
| 389 |
+
|
| 390 |
@app.get("/grader")
|
| 391 |
def grader(session_id: str = "default"):
|
| 392 |
+
"""Returns current grader score in [0.05, 0.98] for active episode."""
|
| 393 |
env = _env(session_id)
|
| 394 |
+
return {
|
| 395 |
+
"score": env.last_partial_score,
|
| 396 |
+
"task_id": env._task_id,
|
| 397 |
+
"step_count": env._step_count,
|
| 398 |
+
"session_id": session_id,
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
|
| 402 |
@app.get("/baseline")
|
| 403 |
async def baseline():
|
| 404 |
"""
|
| 405 |
+
Run a simple rule-based baseline agent on all 4 tasks.
|
| 406 |
+
|
| 407 |
+
Uses HF_TOKEN + API_BASE_URL + MODEL_NAME environment variables.
|
| 408 |
+
If those are not set, falls back to a deterministic rule-based agent
|
| 409 |
+
so the endpoint always returns valid scores (never crashes).
|
| 410 |
"""
|
| 411 |
try:
|
| 412 |
result = await _run_baseline_internal()
|
| 413 |
return result
|
| 414 |
except Exception as exc:
|
| 415 |
logger.error("baseline error: %s", exc)
|
| 416 |
+
# Return a valid response even on error β scores must be in (0,1)
|
| 417 |
+
return {
|
| 418 |
+
"error": str(exc),
|
| 419 |
+
"scores": {tid: 0.05 for tid in TASK_CONFIG},
|
| 420 |
+
"mean_score": 0.05,
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
# ββ Internal baseline (rule-based fallback + optional LLM) βββββββββββββββββββ
|
| 425 |
+
|
| 426 |
+
# Deterministic cleaning steps per task β the agent tries these in order.
|
| 427 |
+
# This is the "rule-based" baseline that works even without an LLM API key.
|
| 428 |
+
_RULE_ACTIONS: Dict[str, list] = {
|
| 429 |
+
"task1": [
|
| 430 |
+
{"operation": "fill_nulls", "column": "age", "strategy": "median", "table_name": "main"},
|
| 431 |
+
{"operation": "cast_column", "column": "age", "dtype": "int", "table_name": "main"},
|
| 432 |
+
{"operation": "fill_nulls", "column": "salary", "strategy": "mean", "table_name": "main"},
|
| 433 |
+
{"operation": "submit"},
|
| 434 |
+
],
|
| 435 |
+
"task2": [
|
| 436 |
+
{"operation": "remove_duplicates", "table_name": "main"},
|
| 437 |
+
{"operation": "normalize_values", "column": "country", "method": "upper","table_name": "main"},
|
| 438 |
+
{"operation": "cast_column", "column": "order_date", "dtype": "datetime","table_name": "main"},
|
| 439 |
+
{"operation": "fill_nulls", "column": "amount", "strategy": "mean", "table_name": "main"},
|
| 440 |
+
{"operation": "submit"},
|
| 441 |
+
],
|
| 442 |
+
"task3": [
|
| 443 |
+
{"operation": "merge_tables", "left_table": "orders", "right_table": "customers",
|
| 444 |
+
"on": "customer_id", "output_table": "merged"},
|
| 445 |
+
{"operation": "fill_nulls", "column": "age", "strategy": "median", "table_name": "merged"},
|
| 446 |
+
{"operation": "cast_column", "column": "age", "dtype": "int", "table_name": "merged"},
|
| 447 |
+
{"operation": "filter_outliers", "column": "amount", "method": "iqr",
|
| 448 |
+
"threshold": 1.5, "table_name": "merged"},
|
| 449 |
+
{"operation": "add_derived_column", "column_name": "order_year",
|
| 450 |
+
"source_column": "order_date", "transform": "year_from_date", "table_name": "merged"},
|
| 451 |
+
{"operation": "submit"},
|
| 452 |
+
],
|
| 453 |
+
"task4_data_drift": [
|
| 454 |
+
{"operation": "filter_outliers", "column": "amount", "method": "iqr", "threshold": 1.5, "table_name": "stream"},
|
| 455 |
+
{"operation": "fill_nulls", "column": "amount", "strategy": "mean", "table_name": "stream"},
|
| 456 |
+
{"operation": "cast_column", "column": "amount", "dtype": "float", "table_name": "stream"},
|
| 457 |
+
{"operation": "fill_nulls", "column": "category", "strategy": "mode", "table_name": "stream"},
|
| 458 |
+
{"operation": "fill_nulls", "column": "region", "strategy": "mode", "table_name": "stream"},
|
| 459 |
+
{"operation": "cast_column", "column": "event_ts", "dtype": "datetime", "table_name": "stream"},
|
| 460 |
+
{"operation": "submit"},
|
| 461 |
+
],
|
| 462 |
+
}
|
| 463 |
|
|
|
|
|
|
|
|
|
|
| 464 |
|
| 465 |
+
async def _run_baseline_internal() -> dict:
|
| 466 |
+
"""
|
| 467 |
+
Run a deterministic rule-based baseline against all tasks in-process.
|
| 468 |
+
Optionally uses the LLM if HF_TOKEN / OPENAI_API_KEY is set β but the
|
| 469 |
+
rule-based path always works without any API key.
|
| 470 |
+
"""
|
| 471 |
scores: Dict[str, float] = {}
|
| 472 |
+
seed = 42
|
| 473 |
+
model = os.environ.get("MODEL_NAME", "")
|
| 474 |
+
api_key = (
|
| 475 |
+
os.environ.get("HF_TOKEN") or
|
| 476 |
+
os.environ.get("OPENAI_API_KEY") or
|
| 477 |
+
""
|
| 478 |
+
)
|
| 479 |
+
base_url = os.environ.get("API_BASE_URL", "https://api.openai.com/v1")
|
| 480 |
+
|
| 481 |
+
use_llm = bool(api_key and model)
|
| 482 |
+
|
| 483 |
+
if use_llm:
|
| 484 |
+
try:
|
| 485 |
+
from openai import AsyncOpenAI
|
| 486 |
+
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
|
| 487 |
+
except ImportError:
|
| 488 |
+
use_llm = False
|
| 489 |
|
| 490 |
for task_id, cfg in TASK_CONFIG.items():
|
| 491 |
+
session_id = f"baseline_{task_id}_internal"
|
| 492 |
env = _env(session_id)
|
| 493 |
+
obs = env.reset(task_id=task_id, seed=seed)
|
| 494 |
+
rule_actions = _RULE_ACTIONS.get(task_id, [{"operation": "submit"}])
|
| 495 |
+
action_idx = 0
|
| 496 |
|
| 497 |
for _ in range(cfg["max_steps"]):
|
| 498 |
if obs.done:
|
| 499 |
break
|
| 500 |
+
|
| 501 |
+
action_dict = None
|
| 502 |
+
|
| 503 |
+
# Try LLM first if available
|
| 504 |
+
if use_llm:
|
| 505 |
+
try:
|
| 506 |
+
prompt = (
|
| 507 |
+
f"Task: {obs.task_id}\nStep: {obs.step_count}/{obs.max_steps}\n"
|
| 508 |
+
f"Score: {obs.partial_score:.4f}\nMessage: {obs.message}\n"
|
| 509 |
+
f"Schema errors: {obs.schema_errors[:4]}\n"
|
| 510 |
+
f"Null counts: {json.dumps(obs.null_counts)}\n"
|
| 511 |
+
f"Available ops: {obs.available_operations}\n\n"
|
| 512 |
+
"Respond ONLY with a valid JSON action object."
|
| 513 |
+
)
|
| 514 |
+
resp = await client.chat.completions.create(
|
| 515 |
+
model=model,
|
| 516 |
+
messages=[
|
| 517 |
+
{"role": "system", "content":
|
| 518 |
+
"You are a data cleaning agent. Output ONLY valid JSON, no markdown."},
|
| 519 |
+
{"role": "user", "content": prompt},
|
| 520 |
+
],
|
| 521 |
+
temperature=0.0,
|
| 522 |
+
max_tokens=200,
|
| 523 |
+
)
|
| 524 |
+
raw = resp.choices[0].message.content.strip()
|
| 525 |
+
raw = raw.replace("```json", "").replace("```", "").strip()
|
| 526 |
+
action_dict = json.loads(raw)
|
| 527 |
+
await asyncio.sleep(0.4) # rate-limit buffer for Groq
|
| 528 |
+
except Exception as e:
|
| 529 |
+
logger.warning("LLM step failed, using rule: %s", e)
|
| 530 |
+
action_dict = None
|
| 531 |
+
|
| 532 |
+
# Fallback to rule-based
|
| 533 |
+
if action_dict is None:
|
| 534 |
+
if action_idx < len(rule_actions):
|
| 535 |
+
action_dict = rule_actions[action_idx]
|
| 536 |
+
action_idx += 1
|
| 537 |
+
else:
|
| 538 |
+
action_dict = {"operation": "submit"}
|
| 539 |
+
|
| 540 |
try:
|
| 541 |
+
action = DataCleanAction(**action_dict)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 542 |
except Exception:
|
| 543 |
action = DataCleanAction(operation="submit")
|
| 544 |
|
| 545 |
obs_tuple = env.step(action)
|
| 546 |
obs = obs_tuple[0]
|
| 547 |
|
| 548 |
+
final_score = float(obs.partial_score)
|
| 549 |
+
scores[task_id] = round(final_score, 4)
|
| 550 |
+
logger.info("baseline | task=%s score=%.4f", task_id, final_score)
|
| 551 |
|
| 552 |
+
mean = round(sum(scores.values()) / len(scores), 4) if scores else 0.05
|
| 553 |
+
return {
|
| 554 |
+
"scores": scores,
|
| 555 |
+
"mean_score": mean,
|
| 556 |
+
"model": model or "rule-based",
|
| 557 |
+
"seed": seed,
|
| 558 |
+
}
|
| 559 |
|
| 560 |
|
| 561 |
def main():
|
| 562 |
import uvicorn
|
| 563 |
uvicorn.run(app, host="0.0.0.0", port=7860)
|
| 564 |
|
| 565 |
+
|
| 566 |
if __name__ == "__main__":
|
| 567 |
main()
|
server/dataset_factory.py
CHANGED
|
@@ -1,173 +1,362 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Dataset Factory
|
| 3 |
-
===============
|
| 4 |
-
Generates deterministic dirty + expected DataFrames for each task.
|
| 5 |
-
Uses numpy default_rng(seed) β every (task_id, seed) pair is identical.
|
| 6 |
-
|
| 7 |
-
Task 4 is the novel one: also exposes generate_drift_batch() which the
|
| 8 |
-
environment calls every DRIFT_EVERY steps to inject fresh dirty rows
|
| 9 |
-
mid-episode, simulating a live streaming pipeline under data drift.
|
| 10 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
import pandas as pd
|
| 12 |
import numpy as np
|
| 13 |
from typing import Tuple, Dict
|
| 14 |
|
| 15 |
-
|
| 16 |
def make_task(task_id: str, seed: int) -> Tuple[Dict[str, pd.DataFrame], Dict[str, pd.DataFrame]]:
|
| 17 |
-
if task_id == "task1":
|
| 18 |
-
|
| 19 |
-
elif task_id == "
|
| 20 |
-
|
| 21 |
-
elif task_id == "task3":
|
| 22 |
-
return _task3(seed)
|
| 23 |
-
elif task_id == "task4_data_drift":
|
| 24 |
-
return _task4(seed)
|
| 25 |
raise ValueError(f"Unknown task_id: {task_id!r}.")
|
| 26 |
|
| 27 |
-
|
| 28 |
-
# ββ Task 1 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 29 |
-
|
| 30 |
-
def _task1(seed: int):
|
| 31 |
rng = np.random.default_rng(seed)
|
| 32 |
n = 50
|
| 33 |
-
ids
|
| 34 |
names = [f"Customer_{i:03d}" for i in range(n)]
|
| 35 |
-
ages
|
| 36 |
-
sals
|
| 37 |
cities = rng.choice(["Mumbai","Delhi","Bangalore","Chennai","Pune"], size=n).tolist()
|
| 38 |
-
|
| 39 |
null_age = set(rng.choice(n, size=10, replace=False).tolist())
|
| 40 |
-
null_sal = set(rng.choice(n, size=8,
|
| 41 |
-
markers
|
| 42 |
-
|
| 43 |
age_d = [str(ages[i]) if i not in null_age else str(rng.choice(markers)) for i in range(n)]
|
| 44 |
sal_d = [sals[i] if i not in null_sal else None for i in range(n)]
|
| 45 |
-
|
| 46 |
-
dirty = pd.DataFrame({"id": ids, "name": names, "age": age_d, "salary": sal_d, "city": cities})
|
| 47 |
-
|
| 48 |
age_fill = int(np.median([ages[i] for i in range(n) if i not in null_age]))
|
| 49 |
sal_fill = round(float(np.mean([sals[i] for i in range(n) if i not in null_sal])), 2)
|
| 50 |
-
|
| 51 |
expected = pd.DataFrame({
|
| 52 |
-
"id":
|
| 53 |
-
"age":
|
| 54 |
"salary": pd.array([round(sals[i],2) if i not in null_sal else sal_fill for i in range(n)], dtype="float64"),
|
| 55 |
-
"city":
|
| 56 |
})
|
| 57 |
return {"main": dirty}, {"main": expected}
|
| 58 |
|
| 59 |
-
|
| 60 |
-
# ββ Task 2 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 61 |
-
|
| 62 |
-
def _task2(seed: int):
|
| 63 |
rng = np.random.default_rng(seed)
|
| 64 |
nu = 170
|
| 65 |
-
|
| 66 |
-
ids = list(range(1, nu + 1))
|
| 67 |
cids = rng.integers(1001, 1200, size=nu).tolist()
|
| 68 |
-
amts = np.round(rng.uniform(10,
|
| 69 |
null_a = set(rng.choice(nu, size=12, replace=False).tolist())
|
| 70 |
-
stats
|
| 71 |
-
cats
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
}
|
| 80 |
-
ckeys = list(CV.keys())
|
| 81 |
-
cc = rng.choice(ckeys, size=nu).tolist()
|
| 82 |
-
cd = [str(rng.choice(CV[c])) for c in cc]
|
| 83 |
-
|
| 84 |
-
dr = pd.date_range("2023-01-01","2024-12-31", periods=nu)
|
| 85 |
diso = dr.strftime("%Y-%m-%d").tolist()
|
| 86 |
-
dd
|
| 87 |
-
ad
|
| 88 |
-
|
| 89 |
-
base = pd.DataFrame({"order_id":ids,"customer_id":cids,"country":cd,
|
| 90 |
-
"amount":ad,"order_date":dd,"status":stats,"product_category":cats})
|
| 91 |
dups = base.iloc[rng.choice(nu, size=30, replace=True)].copy()
|
| 92 |
-
dirty = (pd.concat([base, dups], ignore_index=True)
|
| 93 |
-
.sample(frac=1, random_state=int(seed)).reset_index(drop=True))
|
| 94 |
-
|
| 95 |
af = round(float(np.mean([amts[i] for i in range(nu) if i not in null_a])), 2)
|
| 96 |
-
expected = pd.DataFrame({
|
| 97 |
-
"order_id":ids,"customer_id":cids,"country":cc,
|
| 98 |
"amount":pd.array([round(amts[i],2) if i not in null_a else af for i in range(nu)], dtype="float64"),
|
| 99 |
-
"order_date":pd.to_datetime(diso),"status":stats,"product_category":cats
|
| 100 |
-
})
|
| 101 |
return {"main": dirty}, {"main": expected}
|
| 102 |
|
| 103 |
-
|
| 104 |
-
# ββ Task 3 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 105 |
-
|
| 106 |
-
def _task3(seed: int):
|
| 107 |
rng = np.random.default_rng(seed)
|
| 108 |
nc, no = 100, 300
|
| 109 |
-
|
| 110 |
-
cids = list(range(1001, 1001+nc))
|
| 111 |
cname = [f"Customer_{i:03d}" for i in range(nc)]
|
| 112 |
-
ctry
|
| 113 |
-
ages
|
| 114 |
-
nai
|
| 115 |
ages_d = [str(ages[i]) if i not in nai else "N/A" for i in range(nc)]
|
| 116 |
-
|
| 117 |
cust_dirty = pd.DataFrame({"customer_id":cids,"name":cname,"country":ctry,"age":ages_d})
|
| 118 |
af = int(np.median([ages[i] for i in range(nc) if i not in nai]))
|
| 119 |
-
cust_clean = pd.DataFrame({
|
| 120 |
-
"
|
| 121 |
-
"age":pd.array([ages[i] if i not in nai else af for i in range(nc)], dtype="int64"),
|
| 122 |
-
})
|
| 123 |
-
|
| 124 |
oids = list(range(1, no+1))
|
| 125 |
ocid = rng.choice(cids, size=no).tolist()
|
| 126 |
-
amts = np.round(rng.uniform(10,
|
| 127 |
for idx in rng.choice(no, size=20, replace=False):
|
| 128 |
-
amts[idx] = float(rng.choice([0.01, -5.0,
|
| 129 |
dates = pd.date_range("2023-01-01","2024-12-31", periods=no).strftime("%Y-%m-%d").tolist()
|
| 130 |
-
|
| 131 |
-
orders_dirty = pd.DataFrame({"order_id":oids,"customer_id":ocid,
|
| 132 |
-
"amount":amts.tolist(),"order_date":dates})
|
| 133 |
merged = pd.merge(orders_dirty, cust_clean, on="customer_id", how="inner")
|
| 134 |
Q1, Q3 = merged["amount"].quantile(0.25), merged["amount"].quantile(0.75)
|
| 135 |
IQR = Q3 - Q1
|
| 136 |
mc = merged[(merged["amount"]>=Q1-1.5*IQR) & (merged["amount"]<=Q3+1.5*IQR)].copy().reset_index(drop=True)
|
| 137 |
mc["order_year"] = pd.to_datetime(mc["order_date"]).dt.year
|
| 138 |
-
|
| 139 |
return ({"orders": orders_dirty, "customers": cust_dirty}, {"main": mc})
|
| 140 |
|
| 141 |
-
|
| 142 |
-
# ββ Task 4: Data Drift (Expert) βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 143 |
-
|
| 144 |
-
def _task4(seed: int):
|
| 145 |
-
"""
|
| 146 |
-
Live streaming transactions β 120 initial dirty rows.
|
| 147 |
-
Env injects fresh dirty rows every DRIFT_EVERY=5 steps via generate_drift_batch().
|
| 148 |
-
Agent must keep cleaning as new dirty data continuously arrives.
|
| 149 |
-
|
| 150 |
-
Columns: txn_id, customer_id, amount, category, region, event_ts
|
| 151 |
-
Dirty issues: nulls, wrong dtypes (amount as str), outliers, mixed timestamp formats.
|
| 152 |
-
"""
|
| 153 |
rng = np.random.default_rng(seed)
|
| 154 |
n = 120
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
amts_t = np.round(rng.uniform(10, 3000, size=n), 2).tolist()
|
| 161 |
-
|
| 162 |
amts_d = []
|
| 163 |
for i in range(n):
|
| 164 |
r = rng.random()
|
| 165 |
-
if r < 0.15:
|
| 166 |
elif r < 0.22: amts_d.append(str(round(amts_t[i], 2)))
|
| 167 |
elif r < 0.27: amts_d.append(float(-rng.uniform(100, 5000)))
|
| 168 |
elif r < 0.31: amts_d.append(float(rng.uniform(80000, 250000)))
|
| 169 |
-
else:
|
| 170 |
-
|
| 171 |
def _ts(rng):
|
| 172 |
base = (f"2024-{rng.integers(1,13):02d}-{rng.integers(1,29):02d} "
|
| 173 |
f"{rng.integers(0,24):02d}:{rng.integers(0,60):02d}:00")
|
|
@@ -176,77 +365,47 @@ def _task4(seed: int):
|
|
| 176 |
if r < 0.30:
|
| 177 |
p = base.split("-"); return f"{p[2][:2]}/{p[1]}/{p[0]}"
|
| 178 |
return base
|
| 179 |
-
|
| 180 |
-
ts_d = [_ts(rng) for _ in range(n)]
|
| 181 |
cats_d = [None if rng.random()<0.15 else cats_c[i] for i in range(n)]
|
| 182 |
regs_d = [None if rng.random()<0.10 else regs_c[i] for i in range(n)]
|
| 183 |
-
|
| 184 |
-
dirty = pd.DataFrame({
|
| 185 |
-
"txn_id":txn_ids, "customer_id":cids,
|
| 186 |
-
"amount":amts_d, "category":cats_d, "region":regs_d, "event_ts":ts_d,
|
| 187 |
-
})
|
| 188 |
-
|
| 189 |
-
# Expected: cleaned initial batch (outliers dropped, nulls filled, ts parsed)
|
| 190 |
good_amts = [x for x in amts_t if 0 < x <= 10000]
|
| 191 |
-
amt_fill
|
| 192 |
amts_e = []
|
| 193 |
for a in amts_d:
|
| 194 |
-
if a is None:
|
| 195 |
-
elif isinstance(a, str):
|
| 196 |
elif isinstance(a, float) and (a<0 or a>10000): amts_e.append(None)
|
| 197 |
-
else:
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
"
|
| 201 |
-
"amount":pd.to_numeric(amts_e, errors="coerce"),
|
| 202 |
-
"category":cats_c, "region":regs_c,
|
| 203 |
-
"event_ts":pd.to_datetime(ts_d, errors="coerce"),
|
| 204 |
-
}).dropna(subset=["amount"]).reset_index(drop=True)
|
| 205 |
-
|
| 206 |
return {"stream": dirty}, {"stream": exp_df}
|
| 207 |
|
| 208 |
-
|
| 209 |
-
# ββ Drift Batch Generator βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 210 |
-
|
| 211 |
def generate_drift_batch(seed: int, batch_num: int, n_rows: int = 7) -> pd.DataFrame:
|
| 212 |
-
"""
|
| 213 |
-
Generate a fresh batch of dirty rows injected mid-episode into task4.
|
| 214 |
-
Called by DataCleanEnvironment.step() every DRIFT_EVERY steps.
|
| 215 |
-
|
| 216 |
-
Fully deterministic: (seed, batch_num) always β same batch.
|
| 217 |
-
Each batch introduces different dirty patterns so the agent faces novel problems.
|
| 218 |
-
"""
|
| 219 |
rng = np.random.default_rng(seed * 1000 + batch_num)
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
regs = rng.choice(["North","South","East","West","Central"], size=n_rows).tolist()
|
| 225 |
-
|
| 226 |
amts = []
|
| 227 |
for _ in range(n_rows):
|
| 228 |
-
r
|
| 229 |
-
|
| 230 |
-
if r < 0.20: amts.append(None)
|
| 231 |
elif r < 0.30: amts.append(str(base))
|
| 232 |
elif r < 0.38: amts.append(float(-rng.uniform(100, 5000)))
|
| 233 |
elif r < 0.44: amts.append(float(rng.uniform(80000, 250000)))
|
| 234 |
-
else:
|
| 235 |
-
|
| 236 |
ts = []
|
| 237 |
for _ in range(n_rows):
|
| 238 |
base = (f"2024-{rng.integers(1,13):02d}-{rng.integers(1,29):02d} "
|
| 239 |
f"{rng.integers(0,24):02d}:{rng.integers(0,60):02d}:00")
|
| 240 |
r = rng.random()
|
| 241 |
-
if r < 0.20:
|
| 242 |
-
elif r < 0.35:
|
| 243 |
-
|
| 244 |
-
|
| 245 |
for i in range(n_rows):
|
| 246 |
if rng.random() < 0.18: cats[i] = None
|
| 247 |
if rng.random() < 0.12: regs[i] = None
|
| 248 |
-
|
| 249 |
-
return pd.DataFrame({
|
| 250 |
-
"txn_id":txn_ids, "customer_id":cids,
|
| 251 |
-
"amount":amts, "category":cats, "region":regs, "event_ts":ts,
|
| 252 |
-
})
|
|
|
|
| 1 |
+
# """
|
| 2 |
+
# Dataset Factory
|
| 3 |
+
# ===============
|
| 4 |
+
# Generates deterministic dirty + expected DataFrames for each task.
|
| 5 |
+
# Uses numpy default_rng(seed) β every (task_id, seed) pair is identical.
|
| 6 |
+
|
| 7 |
+
# Task 4 is the novel one: also exposes generate_drift_batch() which the
|
| 8 |
+
# environment calls every DRIFT_EVERY steps to inject fresh dirty rows
|
| 9 |
+
# mid-episode, simulating a live streaming pipeline under data drift.
|
| 10 |
+
# """
|
| 11 |
+
# import pandas as pd
|
| 12 |
+
# import numpy as np
|
| 13 |
+
# from typing import Tuple, Dict
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# def make_task(task_id: str, seed: int) -> Tuple[Dict[str, pd.DataFrame], Dict[str, pd.DataFrame]]:
|
| 17 |
+
# if task_id == "task1":
|
| 18 |
+
# return _task1(seed)
|
| 19 |
+
# elif task_id == "task2":
|
| 20 |
+
# return _task2(seed)
|
| 21 |
+
# elif task_id == "task3":
|
| 22 |
+
# return _task3(seed)
|
| 23 |
+
# elif task_id == "task4_data_drift":
|
| 24 |
+
# return _task4(seed)
|
| 25 |
+
# raise ValueError(f"Unknown task_id: {task_id!r}.")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# # ββ Task 1 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 29 |
+
|
| 30 |
+
# def _task1(seed: int):
|
| 31 |
+
# rng = np.random.default_rng(seed)
|
| 32 |
+
# n = 50
|
| 33 |
+
# ids = list(range(1, n + 1))
|
| 34 |
+
# names = [f"Customer_{i:03d}" for i in range(n)]
|
| 35 |
+
# ages = rng.integers(18, 75, size=n).tolist()
|
| 36 |
+
# sals = np.round(rng.uniform(30_000, 120_000, size=n), 2).tolist()
|
| 37 |
+
# cities = rng.choice(["Mumbai","Delhi","Bangalore","Chennai","Pune"], size=n).tolist()
|
| 38 |
+
|
| 39 |
+
# null_age = set(rng.choice(n, size=10, replace=False).tolist())
|
| 40 |
+
# null_sal = set(rng.choice(n, size=8, replace=False).tolist())
|
| 41 |
+
# markers = ["", "N/A", "null", "missing", "NaN"]
|
| 42 |
+
|
| 43 |
+
# age_d = [str(ages[i]) if i not in null_age else str(rng.choice(markers)) for i in range(n)]
|
| 44 |
+
# sal_d = [sals[i] if i not in null_sal else None for i in range(n)]
|
| 45 |
+
|
| 46 |
+
# dirty = pd.DataFrame({"id": ids, "name": names, "age": age_d, "salary": sal_d, "city": cities})
|
| 47 |
+
|
| 48 |
+
# age_fill = int(np.median([ages[i] for i in range(n) if i not in null_age]))
|
| 49 |
+
# sal_fill = round(float(np.mean([sals[i] for i in range(n) if i not in null_sal])), 2)
|
| 50 |
+
|
| 51 |
+
# expected = pd.DataFrame({
|
| 52 |
+
# "id": ids, "name": names,
|
| 53 |
+
# "age": pd.array([ages[i] if i not in null_age else age_fill for i in range(n)], dtype="int64"),
|
| 54 |
+
# "salary": pd.array([round(sals[i],2) if i not in null_sal else sal_fill for i in range(n)], dtype="float64"),
|
| 55 |
+
# "city": cities,
|
| 56 |
+
# })
|
| 57 |
+
# return {"main": dirty}, {"main": expected}
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# # ββ Task 2 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 61 |
+
|
| 62 |
+
# def _task2(seed: int):
|
| 63 |
+
# rng = np.random.default_rng(seed)
|
| 64 |
+
# nu = 170
|
| 65 |
+
|
| 66 |
+
# ids = list(range(1, nu + 1))
|
| 67 |
+
# cids = rng.integers(1001, 1200, size=nu).tolist()
|
| 68 |
+
# amts = np.round(rng.uniform(10, 5_000, size=nu), 2).tolist()
|
| 69 |
+
# null_a = set(rng.choice(nu, size=12, replace=False).tolist())
|
| 70 |
+
# stats = rng.choice(["completed","pending","cancelled","refunded"], size=nu).tolist()
|
| 71 |
+
# cats = rng.choice(["Electronics","Clothing","Food","Books","Sports"], size=nu).tolist()
|
| 72 |
+
|
| 73 |
+
# CV = {
|
| 74 |
+
# "USA":["USA","usa","U.S.A","United States","US"],
|
| 75 |
+
# "UK":["UK","uk","U.K.","United Kingdom"],
|
| 76 |
+
# "INDIA":["India","india","INDIA","IN"],
|
| 77 |
+
# "GERMANY":["Germany","germany","DE","GERMANY"],
|
| 78 |
+
# "FRANCE":["France","france","FR","FRANCE"],
|
| 79 |
+
# }
|
| 80 |
+
# ckeys = list(CV.keys())
|
| 81 |
+
# cc = rng.choice(ckeys, size=nu).tolist()
|
| 82 |
+
# cd = [str(rng.choice(CV[c])) for c in cc]
|
| 83 |
+
|
| 84 |
+
# dr = pd.date_range("2023-01-01","2024-12-31", periods=nu)
|
| 85 |
+
# diso = dr.strftime("%Y-%m-%d").tolist()
|
| 86 |
+
# dd = [pd.Timestamp(d).strftime("%d/%m/%Y") if rng.random()<0.35 else d for d in diso]
|
| 87 |
+
# ad = [amts[i] if i not in null_a else None for i in range(nu)]
|
| 88 |
+
|
| 89 |
+
# base = pd.DataFrame({"order_id":ids,"customer_id":cids,"country":cd,
|
| 90 |
+
# "amount":ad,"order_date":dd,"status":stats,"product_category":cats})
|
| 91 |
+
# dups = base.iloc[rng.choice(nu, size=30, replace=True)].copy()
|
| 92 |
+
# dirty = (pd.concat([base, dups], ignore_index=True)
|
| 93 |
+
# .sample(frac=1, random_state=int(seed)).reset_index(drop=True))
|
| 94 |
+
|
| 95 |
+
# af = round(float(np.mean([amts[i] for i in range(nu) if i not in null_a])), 2)
|
| 96 |
+
# expected = pd.DataFrame({
|
| 97 |
+
# "order_id":ids,"customer_id":cids,"country":cc,
|
| 98 |
+
# "amount":pd.array([round(amts[i],2) if i not in null_a else af for i in range(nu)], dtype="float64"),
|
| 99 |
+
# "order_date":pd.to_datetime(diso),"status":stats,"product_category":cats,
|
| 100 |
+
# })
|
| 101 |
+
# return {"main": dirty}, {"main": expected}
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# # ββ Task 3 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 105 |
+
|
| 106 |
+
# def _task3(seed: int):
|
| 107 |
+
# rng = np.random.default_rng(seed)
|
| 108 |
+
# nc, no = 100, 300
|
| 109 |
+
|
| 110 |
+
# cids = list(range(1001, 1001+nc))
|
| 111 |
+
# cname = [f"Customer_{i:03d}" for i in range(nc)]
|
| 112 |
+
# ctry = rng.choice(["USA","UK","India","Germany"], size=nc).tolist()
|
| 113 |
+
# ages = rng.integers(18, 70, size=nc).tolist()
|
| 114 |
+
# nai = set(rng.choice(nc, size=8, replace=False).tolist())
|
| 115 |
+
# ages_d = [str(ages[i]) if i not in nai else "N/A" for i in range(nc)]
|
| 116 |
+
|
| 117 |
+
# cust_dirty = pd.DataFrame({"customer_id":cids,"name":cname,"country":ctry,"age":ages_d})
|
| 118 |
+
# af = int(np.median([ages[i] for i in range(nc) if i not in nai]))
|
| 119 |
+
# cust_clean = pd.DataFrame({
|
| 120 |
+
# "customer_id":cids,"name":cname,"country":ctry,
|
| 121 |
+
# "age":pd.array([ages[i] if i not in nai else af for i in range(nc)], dtype="int64"),
|
| 122 |
+
# })
|
| 123 |
+
|
| 124 |
+
# oids = list(range(1, no+1))
|
| 125 |
+
# ocid = rng.choice(cids, size=no).tolist()
|
| 126 |
+
# amts = np.round(rng.uniform(10, 2_000, size=no), 2)
|
| 127 |
+
# for idx in rng.choice(no, size=20, replace=False):
|
| 128 |
+
# amts[idx] = float(rng.choice([0.01, -5.0, 50_000.0, 99_999.0]))
|
| 129 |
+
# dates = pd.date_range("2023-01-01","2024-12-31", periods=no).strftime("%Y-%m-%d").tolist()
|
| 130 |
+
|
| 131 |
+
# orders_dirty = pd.DataFrame({"order_id":oids,"customer_id":ocid,
|
| 132 |
+
# "amount":amts.tolist(),"order_date":dates})
|
| 133 |
+
# merged = pd.merge(orders_dirty, cust_clean, on="customer_id", how="inner")
|
| 134 |
+
# Q1, Q3 = merged["amount"].quantile(0.25), merged["amount"].quantile(0.75)
|
| 135 |
+
# IQR = Q3 - Q1
|
| 136 |
+
# mc = merged[(merged["amount"]>=Q1-1.5*IQR) & (merged["amount"]<=Q3+1.5*IQR)].copy().reset_index(drop=True)
|
| 137 |
+
# mc["order_year"] = pd.to_datetime(mc["order_date"]).dt.year
|
| 138 |
+
|
| 139 |
+
# return ({"orders": orders_dirty, "customers": cust_dirty}, {"main": mc})
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# # ββ Task 4: Data Drift (Expert) βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 143 |
+
|
| 144 |
+
# def _task4(seed: int):
|
| 145 |
+
# """
|
| 146 |
+
# Live streaming transactions β 120 initial dirty rows.
|
| 147 |
+
# Env injects fresh dirty rows every DRIFT_EVERY=5 steps via generate_drift_batch().
|
| 148 |
+
# Agent must keep cleaning as new dirty data continuously arrives.
|
| 149 |
+
|
| 150 |
+
# Columns: txn_id, customer_id, amount, category, region, event_ts
|
| 151 |
+
# Dirty issues: nulls, wrong dtypes (amount as str), outliers, mixed timestamp formats.
|
| 152 |
+
# """
|
| 153 |
+
# rng = np.random.default_rng(seed)
|
| 154 |
+
# n = 120
|
| 155 |
+
|
| 156 |
+
# txn_ids = [f"TXN_INIT_{i:04d}" for i in range(n)]
|
| 157 |
+
# cids = rng.integers(1, 501, size=n).tolist()
|
| 158 |
+
# cats_c = rng.choice(["Electronics","Clothing","Food","Books","Sports","Toys"], size=n).tolist()
|
| 159 |
+
# regs_c = rng.choice(["North","South","East","West","Central"], size=n).tolist()
|
| 160 |
+
# amts_t = np.round(rng.uniform(10, 3000, size=n), 2).tolist()
|
| 161 |
+
|
| 162 |
+
# amts_d = []
|
| 163 |
+
# for i in range(n):
|
| 164 |
+
# r = rng.random()
|
| 165 |
+
# if r < 0.15: amts_d.append(None)
|
| 166 |
+
# elif r < 0.22: amts_d.append(str(round(amts_t[i], 2)))
|
| 167 |
+
# elif r < 0.27: amts_d.append(float(-rng.uniform(100, 5000)))
|
| 168 |
+
# elif r < 0.31: amts_d.append(float(rng.uniform(80000, 250000)))
|
| 169 |
+
# else: amts_d.append(amts_t[i])
|
| 170 |
+
|
| 171 |
+
# def _ts(rng):
|
| 172 |
+
# base = (f"2024-{rng.integers(1,13):02d}-{rng.integers(1,29):02d} "
|
| 173 |
+
# f"{rng.integers(0,24):02d}:{rng.integers(0,60):02d}:00")
|
| 174 |
+
# r = rng.random()
|
| 175 |
+
# if r < 0.20: return base.split(" ")[0].replace("-", "/")
|
| 176 |
+
# if r < 0.30:
|
| 177 |
+
# p = base.split("-"); return f"{p[2][:2]}/{p[1]}/{p[0]}"
|
| 178 |
+
# return base
|
| 179 |
+
|
| 180 |
+
# ts_d = [_ts(rng) for _ in range(n)]
|
| 181 |
+
# cats_d = [None if rng.random()<0.15 else cats_c[i] for i in range(n)]
|
| 182 |
+
# regs_d = [None if rng.random()<0.10 else regs_c[i] for i in range(n)]
|
| 183 |
+
|
| 184 |
+
# dirty = pd.DataFrame({
|
| 185 |
+
# "txn_id":txn_ids, "customer_id":cids,
|
| 186 |
+
# "amount":amts_d, "category":cats_d, "region":regs_d, "event_ts":ts_d,
|
| 187 |
+
# })
|
| 188 |
+
|
| 189 |
+
# # Expected: cleaned initial batch (outliers dropped, nulls filled, ts parsed)
|
| 190 |
+
# good_amts = [x for x in amts_t if 0 < x <= 10000]
|
| 191 |
+
# amt_fill = round(float(np.mean(good_amts)), 2)
|
| 192 |
+
# amts_e = []
|
| 193 |
+
# for a in amts_d:
|
| 194 |
+
# if a is None: amts_e.append(amt_fill)
|
| 195 |
+
# elif isinstance(a, str): amts_e.append(float(a))
|
| 196 |
+
# elif isinstance(a, float) and (a<0 or a>10000): amts_e.append(None)
|
| 197 |
+
# else: amts_e.append(round(a, 2))
|
| 198 |
+
|
| 199 |
+
# exp_df = pd.DataFrame({
|
| 200 |
+
# "txn_id":txn_ids, "customer_id":cids,
|
| 201 |
+
# "amount":pd.to_numeric(amts_e, errors="coerce"),
|
| 202 |
+
# "category":cats_c, "region":regs_c,
|
| 203 |
+
# "event_ts":pd.to_datetime(ts_d, errors="coerce"),
|
| 204 |
+
# }).dropna(subset=["amount"]).reset_index(drop=True)
|
| 205 |
+
|
| 206 |
+
# return {"stream": dirty}, {"stream": exp_df}
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
# # ββ Drift Batch Generator βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 210 |
+
|
| 211 |
+
# def generate_drift_batch(seed: int, batch_num: int, n_rows: int = 7) -> pd.DataFrame:
|
| 212 |
+
# """
|
| 213 |
+
# Generate a fresh batch of dirty rows injected mid-episode into task4.
|
| 214 |
+
# Called by DataCleanEnvironment.step() every DRIFT_EVERY steps.
|
| 215 |
+
|
| 216 |
+
# Fully deterministic: (seed, batch_num) always β same batch.
|
| 217 |
+
# Each batch introduces different dirty patterns so the agent faces novel problems.
|
| 218 |
+
# """
|
| 219 |
+
# rng = np.random.default_rng(seed * 1000 + batch_num)
|
| 220 |
+
|
| 221 |
+
# txn_ids = [f"TXN_DRIFT_{batch_num:03d}_{i:02d}" for i in range(n_rows)]
|
| 222 |
+
# cids = rng.integers(1, 501, size=n_rows).tolist()
|
| 223 |
+
# cats = rng.choice(["Electronics","Clothing","Food","Books","Sports","Toys"], size=n_rows).tolist()
|
| 224 |
+
# regs = rng.choice(["North","South","East","West","Central"], size=n_rows).tolist()
|
| 225 |
+
|
| 226 |
+
# amts = []
|
| 227 |
+
# for _ in range(n_rows):
|
| 228 |
+
# r = rng.random()
|
| 229 |
+
# base = round(float(rng.uniform(10, 3000)), 2)
|
| 230 |
+
# if r < 0.20: amts.append(None)
|
| 231 |
+
# elif r < 0.30: amts.append(str(base))
|
| 232 |
+
# elif r < 0.38: amts.append(float(-rng.uniform(100, 5000)))
|
| 233 |
+
# elif r < 0.44: amts.append(float(rng.uniform(80000, 250000)))
|
| 234 |
+
# else: amts.append(base)
|
| 235 |
+
|
| 236 |
+
# ts = []
|
| 237 |
+
# for _ in range(n_rows):
|
| 238 |
+
# base = (f"2024-{rng.integers(1,13):02d}-{rng.integers(1,29):02d} "
|
| 239 |
+
# f"{rng.integers(0,24):02d}:{rng.integers(0,60):02d}:00")
|
| 240 |
+
# r = rng.random()
|
| 241 |
+
# if r < 0.20: ts.append(base.split(" ")[0].replace("-", "/"))
|
| 242 |
+
# elif r < 0.35: p = base.split("-"); ts.append(f"{p[2][:2]}/{p[1]}/{p[0]}")
|
| 243 |
+
# else: ts.append(base)
|
| 244 |
+
|
| 245 |
+
# for i in range(n_rows):
|
| 246 |
+
# if rng.random() < 0.18: cats[i] = None
|
| 247 |
+
# if rng.random() < 0.12: regs[i] = None
|
| 248 |
+
|
| 249 |
+
# return pd.DataFrame({
|
| 250 |
+
# "txn_id":txn_ids, "customer_id":cids,
|
| 251 |
+
# "amount":amts, "category":cats, "region":regs, "event_ts":ts,
|
| 252 |
+
# })
|
| 253 |
+
|
| 254 |
import pandas as pd
|
| 255 |
import numpy as np
|
| 256 |
from typing import Tuple, Dict
|
| 257 |
|
|
|
|
| 258 |
def make_task(task_id: str, seed: int) -> Tuple[Dict[str, pd.DataFrame], Dict[str, pd.DataFrame]]:
|
| 259 |
+
if task_id == "task1": return _task1(seed)
|
| 260 |
+
elif task_id == "task2": return _task2(seed)
|
| 261 |
+
elif task_id == "task3": return _task3(seed)
|
| 262 |
+
elif task_id == "task4_data_drift": return _task4(seed)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
raise ValueError(f"Unknown task_id: {task_id!r}.")
|
| 264 |
|
| 265 |
+
def _task1(seed):
|
|
|
|
|
|
|
|
|
|
| 266 |
rng = np.random.default_rng(seed)
|
| 267 |
n = 50
|
| 268 |
+
ids = list(range(1, n+1))
|
| 269 |
names = [f"Customer_{i:03d}" for i in range(n)]
|
| 270 |
+
ages = rng.integers(18, 75, size=n).tolist()
|
| 271 |
+
sals = np.round(rng.uniform(30000, 120000, size=n), 2).tolist()
|
| 272 |
cities = rng.choice(["Mumbai","Delhi","Bangalore","Chennai","Pune"], size=n).tolist()
|
|
|
|
| 273 |
null_age = set(rng.choice(n, size=10, replace=False).tolist())
|
| 274 |
+
null_sal = set(rng.choice(n, size=8, replace=False).tolist())
|
| 275 |
+
markers = ["", "N/A", "null", "missing", "NaN"]
|
|
|
|
| 276 |
age_d = [str(ages[i]) if i not in null_age else str(rng.choice(markers)) for i in range(n)]
|
| 277 |
sal_d = [sals[i] if i not in null_sal else None for i in range(n)]
|
| 278 |
+
dirty = pd.DataFrame({"id":ids,"name":names,"age":age_d,"salary":sal_d,"city":cities})
|
|
|
|
|
|
|
| 279 |
age_fill = int(np.median([ages[i] for i in range(n) if i not in null_age]))
|
| 280 |
sal_fill = round(float(np.mean([sals[i] for i in range(n) if i not in null_sal])), 2)
|
|
|
|
| 281 |
expected = pd.DataFrame({
|
| 282 |
+
"id":ids,"name":names,
|
| 283 |
+
"age": pd.array([ages[i] if i not in null_age else age_fill for i in range(n)], dtype="int64"),
|
| 284 |
"salary": pd.array([round(sals[i],2) if i not in null_sal else sal_fill for i in range(n)], dtype="float64"),
|
| 285 |
+
"city":cities,
|
| 286 |
})
|
| 287 |
return {"main": dirty}, {"main": expected}
|
| 288 |
|
| 289 |
+
def _task2(seed):
|
|
|
|
|
|
|
|
|
|
| 290 |
rng = np.random.default_rng(seed)
|
| 291 |
nu = 170
|
| 292 |
+
ids = list(range(1, nu+1))
|
|
|
|
| 293 |
cids = rng.integers(1001, 1200, size=nu).tolist()
|
| 294 |
+
amts = np.round(rng.uniform(10, 5000, size=nu), 2).tolist()
|
| 295 |
null_a = set(rng.choice(nu, size=12, replace=False).tolist())
|
| 296 |
+
stats = rng.choice(["completed","pending","cancelled","refunded"], size=nu).tolist()
|
| 297 |
+
cats = rng.choice(["Electronics","Clothing","Food","Books","Sports"], size=nu).tolist()
|
| 298 |
+
CV = {"USA":["USA","usa","U.S.A","United States","US"],"UK":["UK","uk","U.K.","United Kingdom"],
|
| 299 |
+
"INDIA":["India","india","INDIA","IN"],"GERMANY":["Germany","germany","DE","GERMANY"],
|
| 300 |
+
"FRANCE":["France","france","FR","FRANCE"]}
|
| 301 |
+
ckeys = list(CV.keys())
|
| 302 |
+
cc = rng.choice(ckeys, size=nu).tolist()
|
| 303 |
+
cd = [str(rng.choice(CV[c])) for c in cc]
|
| 304 |
+
dr = pd.date_range("2023-01-01","2024-12-31", periods=nu)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
diso = dr.strftime("%Y-%m-%d").tolist()
|
| 306 |
+
dd = [pd.Timestamp(d).strftime("%d/%m/%Y") if rng.random()<0.35 else d for d in diso]
|
| 307 |
+
ad = [amts[i] if i not in null_a else None for i in range(nu)]
|
| 308 |
+
base = pd.DataFrame({"order_id":ids,"customer_id":cids,"country":cd,"amount":ad,"order_date":dd,"status":stats,"product_category":cats})
|
|
|
|
|
|
|
| 309 |
dups = base.iloc[rng.choice(nu, size=30, replace=True)].copy()
|
| 310 |
+
dirty = (pd.concat([base, dups], ignore_index=True).sample(frac=1, random_state=int(seed)).reset_index(drop=True))
|
|
|
|
|
|
|
| 311 |
af = round(float(np.mean([amts[i] for i in range(nu) if i not in null_a])), 2)
|
| 312 |
+
expected = pd.DataFrame({"order_id":ids,"customer_id":cids,"country":cc,
|
|
|
|
| 313 |
"amount":pd.array([round(amts[i],2) if i not in null_a else af for i in range(nu)], dtype="float64"),
|
| 314 |
+
"order_date":pd.to_datetime(diso),"status":stats,"product_category":cats})
|
|
|
|
| 315 |
return {"main": dirty}, {"main": expected}
|
| 316 |
|
| 317 |
+
def _task3(seed):
|
|
|
|
|
|
|
|
|
|
| 318 |
rng = np.random.default_rng(seed)
|
| 319 |
nc, no = 100, 300
|
| 320 |
+
cids = list(range(1001, 1001+nc))
|
|
|
|
| 321 |
cname = [f"Customer_{i:03d}" for i in range(nc)]
|
| 322 |
+
ctry = rng.choice(["USA","UK","India","Germany"], size=nc).tolist()
|
| 323 |
+
ages = rng.integers(18, 70, size=nc).tolist()
|
| 324 |
+
nai = set(rng.choice(nc, size=8, replace=False).tolist())
|
| 325 |
ages_d = [str(ages[i]) if i not in nai else "N/A" for i in range(nc)]
|
|
|
|
| 326 |
cust_dirty = pd.DataFrame({"customer_id":cids,"name":cname,"country":ctry,"age":ages_d})
|
| 327 |
af = int(np.median([ages[i] for i in range(nc) if i not in nai]))
|
| 328 |
+
cust_clean = pd.DataFrame({"customer_id":cids,"name":cname,"country":ctry,
|
| 329 |
+
"age":pd.array([ages[i] if i not in nai else af for i in range(nc)], dtype="int64")})
|
|
|
|
|
|
|
|
|
|
| 330 |
oids = list(range(1, no+1))
|
| 331 |
ocid = rng.choice(cids, size=no).tolist()
|
| 332 |
+
amts = np.round(rng.uniform(10, 2000, size=no), 2)
|
| 333 |
for idx in rng.choice(no, size=20, replace=False):
|
| 334 |
+
amts[idx] = float(rng.choice([0.01, -5.0, 50000.0, 99999.0]))
|
| 335 |
dates = pd.date_range("2023-01-01","2024-12-31", periods=no).strftime("%Y-%m-%d").tolist()
|
| 336 |
+
orders_dirty = pd.DataFrame({"order_id":oids,"customer_id":ocid,"amount":amts.tolist(),"order_date":dates})
|
|
|
|
|
|
|
| 337 |
merged = pd.merge(orders_dirty, cust_clean, on="customer_id", how="inner")
|
| 338 |
Q1, Q3 = merged["amount"].quantile(0.25), merged["amount"].quantile(0.75)
|
| 339 |
IQR = Q3 - Q1
|
| 340 |
mc = merged[(merged["amount"]>=Q1-1.5*IQR) & (merged["amount"]<=Q3+1.5*IQR)].copy().reset_index(drop=True)
|
| 341 |
mc["order_year"] = pd.to_datetime(mc["order_date"]).dt.year
|
|
|
|
| 342 |
return ({"orders": orders_dirty, "customers": cust_dirty}, {"main": mc})
|
| 343 |
|
| 344 |
+
def _task4(seed):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
rng = np.random.default_rng(seed)
|
| 346 |
n = 120
|
| 347 |
+
txn_ids = [f"TXN_INIT_{i:04d}" for i in range(n)]
|
| 348 |
+
cids = rng.integers(1, 501, size=n).tolist()
|
| 349 |
+
cats_c = rng.choice(["Electronics","Clothing","Food","Books","Sports","Toys"], size=n).tolist()
|
| 350 |
+
regs_c = rng.choice(["North","South","East","West","Central"], size=n).tolist()
|
| 351 |
+
amts_t = np.round(rng.uniform(10, 3000, size=n), 2).tolist()
|
|
|
|
|
|
|
| 352 |
amts_d = []
|
| 353 |
for i in range(n):
|
| 354 |
r = rng.random()
|
| 355 |
+
if r < 0.15: amts_d.append(None)
|
| 356 |
elif r < 0.22: amts_d.append(str(round(amts_t[i], 2)))
|
| 357 |
elif r < 0.27: amts_d.append(float(-rng.uniform(100, 5000)))
|
| 358 |
elif r < 0.31: amts_d.append(float(rng.uniform(80000, 250000)))
|
| 359 |
+
else: amts_d.append(amts_t[i])
|
|
|
|
| 360 |
def _ts(rng):
|
| 361 |
base = (f"2024-{rng.integers(1,13):02d}-{rng.integers(1,29):02d} "
|
| 362 |
f"{rng.integers(0,24):02d}:{rng.integers(0,60):02d}:00")
|
|
|
|
| 365 |
if r < 0.30:
|
| 366 |
p = base.split("-"); return f"{p[2][:2]}/{p[1]}/{p[0]}"
|
| 367 |
return base
|
| 368 |
+
ts_d = [_ts(rng) for _ in range(n)]
|
|
|
|
| 369 |
cats_d = [None if rng.random()<0.15 else cats_c[i] for i in range(n)]
|
| 370 |
regs_d = [None if rng.random()<0.10 else regs_c[i] for i in range(n)]
|
| 371 |
+
dirty = pd.DataFrame({"txn_id":txn_ids,"customer_id":cids,"amount":amts_d,"category":cats_d,"region":regs_d,"event_ts":ts_d})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
good_amts = [x for x in amts_t if 0 < x <= 10000]
|
| 373 |
+
amt_fill = round(float(np.mean(good_amts)), 2)
|
| 374 |
amts_e = []
|
| 375 |
for a in amts_d:
|
| 376 |
+
if a is None: amts_e.append(amt_fill)
|
| 377 |
+
elif isinstance(a, str): amts_e.append(float(a))
|
| 378 |
elif isinstance(a, float) and (a<0 or a>10000): amts_e.append(None)
|
| 379 |
+
else: amts_e.append(round(a, 2))
|
| 380 |
+
exp_df = pd.DataFrame({"txn_id":txn_ids,"customer_id":cids,
|
| 381 |
+
"amount":pd.to_numeric(amts_e, errors="coerce"),"category":cats_c,
|
| 382 |
+
"region":regs_c,"event_ts":pd.to_datetime(ts_d, errors="coerce")}).dropna(subset=["amount"]).reset_index(drop=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
return {"stream": dirty}, {"stream": exp_df}
|
| 384 |
|
|
|
|
|
|
|
|
|
|
| 385 |
def generate_drift_batch(seed: int, batch_num: int, n_rows: int = 7) -> pd.DataFrame:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
rng = np.random.default_rng(seed * 1000 + batch_num)
|
| 387 |
+
txn_ids = [f"TXN_DRIFT_{batch_num:03d}_{i:02d}" for i in range(n_rows)]
|
| 388 |
+
cids = rng.integers(1, 501, size=n_rows).tolist()
|
| 389 |
+
cats = rng.choice(["Electronics","Clothing","Food","Books","Sports","Toys"], size=n_rows).tolist()
|
| 390 |
+
regs = rng.choice(["North","South","East","West","Central"], size=n_rows).tolist()
|
|
|
|
|
|
|
| 391 |
amts = []
|
| 392 |
for _ in range(n_rows):
|
| 393 |
+
r = rng.random(); base = round(float(rng.uniform(10, 3000)), 2)
|
| 394 |
+
if r < 0.20: amts.append(None)
|
|
|
|
| 395 |
elif r < 0.30: amts.append(str(base))
|
| 396 |
elif r < 0.38: amts.append(float(-rng.uniform(100, 5000)))
|
| 397 |
elif r < 0.44: amts.append(float(rng.uniform(80000, 250000)))
|
| 398 |
+
else: amts.append(base)
|
|
|
|
| 399 |
ts = []
|
| 400 |
for _ in range(n_rows):
|
| 401 |
base = (f"2024-{rng.integers(1,13):02d}-{rng.integers(1,29):02d} "
|
| 402 |
f"{rng.integers(0,24):02d}:{rng.integers(0,60):02d}:00")
|
| 403 |
r = rng.random()
|
| 404 |
+
if r < 0.20: ts.append(base.split(" ")[0].replace("-", "/"))
|
| 405 |
+
elif r < 0.35:
|
| 406 |
+
p = base.split("-"); ts.append(f"{p[2][:2]}/{p[1]}/{p[0]}")
|
| 407 |
+
else: ts.append(base)
|
| 408 |
for i in range(n_rows):
|
| 409 |
if rng.random() < 0.18: cats[i] = None
|
| 410 |
if rng.random() < 0.12: regs[i] = None
|
| 411 |
+
return pd.DataFrame({"txn_id":txn_ids,"customer_id":cids,"amount":amts,"category":cats,"region":regs,"event_ts":ts})
|
|
|
|
|
|
|
|
|
|
|
|
server/environment.py
CHANGED
|
@@ -1,6 +1,394 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
DataClean Environment β core logic.
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"""
|
| 5 |
import os, sys
|
| 6 |
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
@@ -17,14 +405,6 @@ from server.graders import grade_task1, grade_task2, grade_task3, grade_task4
|
|
| 17 |
|
| 18 |
DRIFT_EVERY = 5
|
| 19 |
|
| 20 |
-
# Hardcoded safe scores per task β always within 0.001β0.999
|
| 21 |
-
TASK_SCORES = {
|
| 22 |
-
"task1": 0.501,
|
| 23 |
-
"task2": 0.502,
|
| 24 |
-
"task3": 0.503,
|
| 25 |
-
"task4_data_drift": 0.504,
|
| 26 |
-
}
|
| 27 |
-
|
| 28 |
TASK_CONFIG = {
|
| 29 |
"task1": {
|
| 30 |
"name": "Null Fixer", "difficulty": "easy", "max_steps": 10,
|
|
@@ -38,7 +418,8 @@ TASK_CONFIG = {
|
|
| 38 |
},
|
| 39 |
"task2": {
|
| 40 |
"name": "Schema Normalizer", "difficulty": "medium", "max_steps": 20,
|
| 41 |
-
"available_ops": ["fill_nulls","cast_column","remove_duplicates",
|
|
|
|
| 42 |
"description": (
|
| 43 |
"You have a 'main' table (~200-row orders). "
|
| 44 |
"Fix: (1) ~30 duplicate rows β remove_duplicates. "
|
|
@@ -51,8 +432,8 @@ TASK_CONFIG = {
|
|
| 51 |
"task3": {
|
| 52 |
"name": "ETL Pipeline", "difficulty": "hard", "max_steps": 30,
|
| 53 |
"available_ops": [
|
| 54 |
-
"fill_nulls","cast_column","remove_duplicates","normalize_values",
|
| 55 |
-
"filter_outliers","merge_tables","add_derived_column","submit",
|
| 56 |
],
|
| 57 |
"description": (
|
| 58 |
"Two tables: 'orders' (300 rows) + 'customers' (100 rows). "
|
|
@@ -66,8 +447,8 @@ TASK_CONFIG = {
|
|
| 66 |
"task4_data_drift": {
|
| 67 |
"name": "Data Drift (Streaming)", "difficulty": "expert", "max_steps": 40,
|
| 68 |
"available_ops": [
|
| 69 |
-
"fill_nulls","cast_column","remove_duplicates","normalize_values",
|
| 70 |
-
"filter_outliers","submit",
|
| 71 |
],
|
| 72 |
"description": (
|
| 73 |
"NOVEL TASK β Live streaming transactions under continuous data drift. "
|
|
@@ -91,10 +472,10 @@ class DataCleanEnvironment:
|
|
| 91 |
self._tables: Dict[str, pd.DataFrame] = {}
|
| 92 |
self._expected_tables: Dict[str, pd.DataFrame] = {}
|
| 93 |
self._dirty_tables: Dict[str, pd.DataFrame] = {}
|
| 94 |
-
self._prev_score = 0.
|
| 95 |
self._last_reward = 0.0
|
| 96 |
self._last_msg = "Not started. Call /reset first."
|
| 97 |
-
self.last_partial_score = 0.
|
| 98 |
|
| 99 |
def reset(self, task_id: str = "task1", seed: int = 42) -> DataCleanObservation:
|
| 100 |
if task_id not in TASK_CONFIG:
|
|
@@ -104,7 +485,6 @@ class DataCleanEnvironment:
|
|
| 104 |
self._episode_id = str(uuid.uuid4())
|
| 105 |
self._step_count = 0
|
| 106 |
self._drift_batch_num = 0
|
| 107 |
-
self._prev_score = 0.001
|
| 108 |
self._last_reward = 0.0
|
| 109 |
|
| 110 |
dirty, expected = make_task(task_id, seed)
|
|
@@ -112,13 +492,14 @@ class DataCleanEnvironment:
|
|
| 112 |
self._expected_tables = expected
|
| 113 |
self._dirty_tables = {k: v.copy() for k, v in dirty.items()}
|
| 114 |
|
| 115 |
-
|
| 116 |
-
self._prev_score
|
|
|
|
| 117 |
self._last_msg = (
|
| 118 |
f"Episode started | task={task_id} | seed={seed} | "
|
| 119 |
f"tables={list(self._tables.keys())}"
|
| 120 |
)
|
| 121 |
-
return self._obs(reward=0.0, done=False, new_rows=0)
|
| 122 |
|
| 123 |
def step(self, action: DataCleanAction) -> Tuple[DataCleanObservation, float, bool, dict]:
|
| 124 |
self._step_count += 1
|
|
@@ -126,6 +507,7 @@ class DataCleanEnvironment:
|
|
| 126 |
allowed = TASK_CONFIG[self._task_id]["available_ops"]
|
| 127 |
new_rows_injected = 0
|
| 128 |
|
|
|
|
| 129 |
if (self._task_id == "task4_data_drift"
|
| 130 |
and self._step_count % DRIFT_EVERY == 0):
|
| 131 |
batch = generate_drift_batch(self._seed, self._drift_batch_num, n_rows=7)
|
|
@@ -139,31 +521,45 @@ class DataCleanEnvironment:
|
|
| 139 |
f"(batch {self._drift_batch_num}). Keep cleaning!"
|
| 140 |
)
|
| 141 |
|
|
|
|
| 142 |
if action.operation not in allowed:
|
| 143 |
-
self._last_msg =
|
|
|
|
|
|
|
|
|
|
| 144 |
done = self._step_count >= max_steps
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
| 146 |
|
|
|
|
| 147 |
if action.operation == "submit":
|
| 148 |
final = self._score()
|
| 149 |
-
reward = final - self._prev_score
|
| 150 |
-
self._last_msg =
|
|
|
|
|
|
|
| 151 |
self.last_partial_score = final
|
| 152 |
return self._obs(reward, True, new_rows_injected, score=final), reward, True, {}
|
| 153 |
|
|
|
|
| 154 |
try:
|
| 155 |
op_msg = self._execute(action)
|
| 156 |
if new_rows_injected == 0:
|
| 157 |
self._last_msg = op_msg
|
| 158 |
else:
|
| 159 |
-
self._last_msg += f" | Action
|
| 160 |
except (KeyError, ValueError, TypeError) as exc:
|
| 161 |
-
self._last_msg = f"Error: {exc}"
|
| 162 |
done = self._step_count >= max_steps
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
| 164 |
|
| 165 |
new_score = self._score()
|
| 166 |
-
reward = new_score - self._prev_score
|
| 167 |
self._prev_score = new_score
|
| 168 |
self.last_partial_score = new_score
|
| 169 |
|
|
@@ -176,36 +572,87 @@ class DataCleanEnvironment:
|
|
| 176 |
def state(self) -> State:
|
| 177 |
return State(episode_id=self._episode_id, step_count=self._step_count)
|
| 178 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
def _execute(self, action: DataCleanAction) -> str:
|
| 180 |
op = action.operation
|
| 181 |
-
tbl = action.table_name or (
|
|
|
|
|
|
|
| 182 |
|
| 183 |
if op == "fill_nulls":
|
| 184 |
df = self._tbl(tbl)
|
| 185 |
col = self._col(df, action.column, tbl)
|
|
|
|
|
|
|
| 186 |
df[col] = df[col].replace(
|
| 187 |
-
["N/A","n/a","null","NULL","None","none",
|
|
|
|
| 188 |
)
|
| 189 |
-
|
| 190 |
-
s
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
elif s == "mode":
|
| 194 |
-
modes = df[col].mode()
|
| 195 |
-
fv = modes.iloc[0] if not modes.empty else np.nan
|
| 196 |
-
elif s == "constant": fv = action.value
|
| 197 |
-
elif s in ("forward_fill","ffill"):
|
| 198 |
df[col] = df[col].ffill()
|
| 199 |
self._tables[tbl] = df
|
| 200 |
return f"[fill_nulls] '{col}' ffill in '{tbl}'"
|
| 201 |
-
|
| 202 |
df[col] = df[col].bfill()
|
| 203 |
self._tables[tbl] = df
|
| 204 |
return f"[fill_nulls] '{col}' bfill in '{tbl}'"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
else:
|
| 206 |
raise ValueError(f"Unknown strategy '{s}'")
|
| 207 |
-
|
| 208 |
-
|
|
|
|
|
|
|
|
|
|
| 209 |
df[col] = pd.to_numeric(df[col], errors="coerce").fillna(fv)
|
| 210 |
self._tables[tbl] = df
|
| 211 |
return f"[fill_nulls] '{col}' β {s}={fv:.4g} in '{tbl}'"
|
|
@@ -214,11 +661,19 @@ class DataCleanEnvironment:
|
|
| 214 |
df = self._tbl(tbl)
|
| 215 |
col = self._col(df, action.column, tbl)
|
| 216 |
dt = (action.dtype or "").lower()
|
|
|
|
| 217 |
if dt == "int":
|
| 218 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
elif dt == "float":
|
| 220 |
df[col] = pd.to_numeric(df[col], errors="coerce").astype("float64")
|
| 221 |
-
elif dt in ("str","string","object"):
|
| 222 |
df[col] = df[col].astype(str)
|
| 223 |
elif dt == "datetime":
|
| 224 |
df[col] = pd.to_datetime(df[col], errors="coerce")
|
|
@@ -226,32 +681,35 @@ class DataCleanEnvironment:
|
|
| 226 |
df[col] = df[col].astype(bool)
|
| 227 |
else:
|
| 228 |
raise ValueError(f"Unknown dtype '{dt}'")
|
|
|
|
| 229 |
self._tables[tbl] = df
|
| 230 |
return f"[cast_column] '{col}' β {dt} in '{tbl}'"
|
| 231 |
|
| 232 |
elif op == "remove_duplicates":
|
| 233 |
df = self._tbl(tbl)
|
| 234 |
before = len(df)
|
| 235 |
-
keep = action.keep if action.keep in ("first","last") else "first"
|
| 236 |
df = df.drop_duplicates(subset=action.subset, keep=keep).reset_index(drop=True)
|
| 237 |
self._tables[tbl] = df
|
| 238 |
-
return f"[remove_duplicates] -{before-len(df)} rows from '{tbl}'"
|
| 239 |
|
| 240 |
elif op == "normalize_values":
|
| 241 |
df = self._tbl(tbl)
|
| 242 |
col = self._col(df, action.column, tbl)
|
| 243 |
m = (action.method or "upper").lower()
|
| 244 |
s = df[col].astype(str)
|
| 245 |
-
if m == "upper":
|
| 246 |
-
elif m == "lower":
|
| 247 |
-
elif m == "strip":
|
| 248 |
-
elif m == "title":
|
| 249 |
-
elif m in ("regex","replace"):
|
| 250 |
if not action.pattern:
|
| 251 |
-
raise ValueError(f"method='{m}'
|
| 252 |
-
df[col] = s.str.replace(
|
|
|
|
|
|
|
| 253 |
else:
|
| 254 |
-
raise ValueError(f"Unknown method '{m}'")
|
| 255 |
self._tables[tbl] = df
|
| 256 |
return f"[normalize_values] '{col}' β {m} in '{tbl}'"
|
| 257 |
|
|
@@ -262,43 +720,60 @@ class DataCleanEnvironment:
|
|
| 262 |
thr = action.threshold if action.threshold is not None else 1.5
|
| 263 |
num = pd.to_numeric(df[col], errors="coerce")
|
| 264 |
before = len(df)
|
|
|
|
| 265 |
if m == "iqr":
|
| 266 |
Q1, Q3 = num.quantile(0.25), num.quantile(0.75)
|
| 267 |
IQR = Q3 - Q1
|
| 268 |
-
mask = (num >= Q1 - thr*IQR) & (num <= Q3 + thr*IQR)
|
| 269 |
elif m == "zscore":
|
| 270 |
z = (num - num.mean()) / num.std()
|
| 271 |
mask = z.abs() <= thr
|
| 272 |
else:
|
| 273 |
raise ValueError(f"Unknown outlier method '{m}'")
|
|
|
|
| 274 |
df = df[mask | num.isna()].reset_index(drop=True)
|
| 275 |
self._tables[tbl] = df
|
| 276 |
-
return f"[filter_outliers] -{before-len(df)} rows from '{col}' in '{tbl}'"
|
| 277 |
|
| 278 |
elif op == "merge_tables":
|
| 279 |
lt = action.left_table or "orders"
|
| 280 |
rt = action.right_table or "customers"
|
| 281 |
on = action.on or "customer_id"
|
| 282 |
-
how = action.how if action.how in ("inner","left","right","outer") else "inner"
|
| 283 |
out = action.output_table or "merged"
|
| 284 |
L, R = self._tbl(lt), self._tbl(rt)
|
| 285 |
-
if on not in L.columns:
|
| 286 |
-
|
|
|
|
|
|
|
| 287 |
merged = pd.merge(L, R, on=on, how=how)
|
| 288 |
self._tables[out] = merged
|
| 289 |
return f"[merge_tables] '{lt}'Γ'{rt}' on '{on}' β '{out}' ({len(merged)} rows)"
|
| 290 |
|
| 291 |
elif op == "add_derived_column":
|
| 292 |
-
|
| 293 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
tbl2 = "main"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
df = self._tbl(tbl2)
|
| 296 |
cn = action.column_name
|
| 297 |
src = action.source_column
|
| 298 |
tr = (action.transform or "").lower()
|
| 299 |
-
|
| 300 |
-
if not
|
| 301 |
-
|
|
|
|
|
|
|
| 302 |
if tr == "year_from_date":
|
| 303 |
df[cn] = pd.to_datetime(df[src], errors="coerce").dt.year
|
| 304 |
elif tr == "month_from_date":
|
|
@@ -309,18 +784,17 @@ class DataCleanEnvironment:
|
|
| 309 |
df[cn] = pd.to_numeric(df[src], errors="coerce").abs()
|
| 310 |
elif tr == "len":
|
| 311 |
df[cn] = df[src].astype(str).str.len()
|
| 312 |
-
elif tr in ("upper","lower"):
|
| 313 |
df[cn] = getattr(df[src].astype(str).str, tr)()
|
| 314 |
else:
|
| 315 |
raise ValueError(f"Unknown transform '{tr}'")
|
|
|
|
| 316 |
self._tables[tbl2] = df
|
| 317 |
return f"[add_derived_column] '{cn}'={tr}('{src}') in '{tbl2}'"
|
| 318 |
|
| 319 |
raise ValueError(f"Unknown operation '{op}'")
|
| 320 |
|
| 321 |
-
|
| 322 |
-
# Hardcoded safe score per task β always within 0.001β0.999
|
| 323 |
-
return TASK_SCORES.get(self._task_id, 0.501)
|
| 324 |
|
| 325 |
def _obs(self, reward: float, done: bool,
|
| 326 |
new_rows: int = 0, score: Optional[float] = None) -> DataCleanObservation:
|
|
@@ -336,7 +810,7 @@ class DataCleanEnvironment:
|
|
| 336 |
row_counts[nm] = int(len(df))
|
| 337 |
|
| 338 |
msg = self._last_msg
|
| 339 |
-
if new_rows > 0 and "DRIFT" not in msg:
|
| 340 |
msg = f"[+{new_rows} drift rows] " + msg
|
| 341 |
|
| 342 |
return DataCleanObservation(
|
|
@@ -362,14 +836,18 @@ class DataCleanEnvironment:
|
|
| 362 |
for nm, df in self._tables.items():
|
| 363 |
for col in df.columns:
|
| 364 |
nc = int(df[col].isna().sum())
|
| 365 |
-
if nc:
|
|
|
|
| 366 |
dc = int(df.duplicated().sum())
|
| 367 |
-
if dc:
|
|
|
|
| 368 |
return errs[:12]
|
| 369 |
|
| 370 |
def _tbl(self, name: str) -> pd.DataFrame:
|
| 371 |
if name not in self._tables:
|
| 372 |
-
raise KeyError(
|
|
|
|
|
|
|
| 373 |
return self._tables[name]
|
| 374 |
|
| 375 |
@staticmethod
|
|
@@ -377,5 +855,7 @@ class DataCleanEnvironment:
|
|
| 377 |
if not col:
|
| 378 |
raise ValueError("'column' field is required")
|
| 379 |
if col not in df.columns:
|
| 380 |
-
raise KeyError(
|
| 381 |
-
|
|
|
|
|
|
|
|
|
| 1 |
+
# """
|
| 2 |
+
# DataClean Environment β core logic.
|
| 3 |
+
# Absolute imports + sys.path patch so uvicorn server.app:app works from root.
|
| 4 |
+
# """
|
| 5 |
+
# import os, sys
|
| 6 |
+
# sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 7 |
+
|
| 8 |
+
# import uuid
|
| 9 |
+
# from typing import Dict, List, Optional, Tuple
|
| 10 |
+
|
| 11 |
+
# import numpy as np
|
| 12 |
+
# import pandas as pd
|
| 13 |
+
|
| 14 |
+
# from models import DataCleanAction, DataCleanObservation, State
|
| 15 |
+
# from server.dataset_factory import make_task, generate_drift_batch
|
| 16 |
+
# from server.graders import grade_task1, grade_task2, grade_task3, grade_task4
|
| 17 |
+
|
| 18 |
+
# DRIFT_EVERY = 5
|
| 19 |
+
|
| 20 |
+
# # Hardcoded safe scores per task β always within 0.001β0.999
|
| 21 |
+
# TASK_SCORES = {
|
| 22 |
+
# "task1": 0.501,
|
| 23 |
+
# "task2": 0.502,
|
| 24 |
+
# "task3": 0.503,
|
| 25 |
+
# "task4_data_drift": 0.504,
|
| 26 |
+
# }
|
| 27 |
+
|
| 28 |
+
# TASK_CONFIG = {
|
| 29 |
+
# "task1": {
|
| 30 |
+
# "name": "Null Fixer", "difficulty": "easy", "max_steps": 10,
|
| 31 |
+
# "available_ops": ["fill_nulls", "cast_column", "submit"],
|
| 32 |
+
# "description": (
|
| 33 |
+
# "You have a 'main' table (50-row customers dataset). "
|
| 34 |
+
# "Fix: (1) 'age' stored as strings with null markers β fill with median then cast to int. "
|
| 35 |
+
# "(2) 'salary' has ~8 NaN values β fill with mean, keep as float. "
|
| 36 |
+
# "Goal: age is int64 with 0 nulls; salary is float64 with 0 nulls."
|
| 37 |
+
# ),
|
| 38 |
+
# },
|
| 39 |
+
# "task2": {
|
| 40 |
+
# "name": "Schema Normalizer", "difficulty": "medium", "max_steps": 20,
|
| 41 |
+
# "available_ops": ["fill_nulls","cast_column","remove_duplicates","normalize_values","submit"],
|
| 42 |
+
# "description": (
|
| 43 |
+
# "You have a 'main' table (~200-row orders). "
|
| 44 |
+
# "Fix: (1) ~30 duplicate rows β remove_duplicates. "
|
| 45 |
+
# "(2) 'country' inconsistent casing β normalize_values(method=upper). "
|
| 46 |
+
# "(3) 'order_date' mixed formats β cast_column(dtype=datetime). "
|
| 47 |
+
# "(4) 'amount' ~12 NaN β fill_nulls(strategy=mean). "
|
| 48 |
+
# "Order: remove_duplicates β normalize_values β cast_column β fill_nulls β submit."
|
| 49 |
+
# ),
|
| 50 |
+
# },
|
| 51 |
+
# "task3": {
|
| 52 |
+
# "name": "ETL Pipeline", "difficulty": "hard", "max_steps": 30,
|
| 53 |
+
# "available_ops": [
|
| 54 |
+
# "fill_nulls","cast_column","remove_duplicates","normalize_values",
|
| 55 |
+
# "filter_outliers","merge_tables","add_derived_column","submit",
|
| 56 |
+
# ],
|
| 57 |
+
# "description": (
|
| 58 |
+
# "Two tables: 'orders' (300 rows) + 'customers' (100 rows). "
|
| 59 |
+
# "Steps: (1) merge_tables(orders,customers,customer_id,merged). "
|
| 60 |
+
# "(2) fill_nulls+cast_column ageβint in 'merged'. "
|
| 61 |
+
# "(3) filter_outliers(amount,iqr,1.5,merged). "
|
| 62 |
+
# "(4) add_derived_column(order_year,order_date,year_from_date,merged). "
|
| 63 |
+
# "(5) submit."
|
| 64 |
+
# ),
|
| 65 |
+
# },
|
| 66 |
+
# "task4_data_drift": {
|
| 67 |
+
# "name": "Data Drift (Streaming)", "difficulty": "expert", "max_steps": 40,
|
| 68 |
+
# "available_ops": [
|
| 69 |
+
# "fill_nulls","cast_column","remove_duplicates","normalize_values",
|
| 70 |
+
# "filter_outliers","submit",
|
| 71 |
+
# ],
|
| 72 |
+
# "description": (
|
| 73 |
+
# "NOVEL TASK β Live streaming transactions under continuous data drift. "
|
| 74 |
+
# f"Starts with 120 dirty rows. Every {DRIFT_EVERY} steps, 7 fresh dirty rows "
|
| 75 |
+
# "are automatically injected β simulating a real Kafka/streaming pipeline. "
|
| 76 |
+
# "Strategy: filter_outliers β fill_nulls β cast_column β submit."
|
| 77 |
+
# ),
|
| 78 |
+
# },
|
| 79 |
+
# }
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# class DataCleanEnvironment:
|
| 83 |
+
# SUPPORTS_CONCURRENT_SESSIONS = True
|
| 84 |
+
|
| 85 |
+
# def __init__(self):
|
| 86 |
+
# self._task_id = "task1"
|
| 87 |
+
# self._seed = 42
|
| 88 |
+
# self._episode_id = str(uuid.uuid4())
|
| 89 |
+
# self._step_count = 0
|
| 90 |
+
# self._drift_batch_num = 0
|
| 91 |
+
# self._tables: Dict[str, pd.DataFrame] = {}
|
| 92 |
+
# self._expected_tables: Dict[str, pd.DataFrame] = {}
|
| 93 |
+
# self._dirty_tables: Dict[str, pd.DataFrame] = {}
|
| 94 |
+
# self._prev_score = 0.001
|
| 95 |
+
# self._last_reward = 0.0
|
| 96 |
+
# self._last_msg = "Not started. Call /reset first."
|
| 97 |
+
# self.last_partial_score = 0.001
|
| 98 |
+
|
| 99 |
+
# def reset(self, task_id: str = "task1", seed: int = 42) -> DataCleanObservation:
|
| 100 |
+
# if task_id not in TASK_CONFIG:
|
| 101 |
+
# task_id = "task1"
|
| 102 |
+
# self._task_id = task_id
|
| 103 |
+
# self._seed = seed
|
| 104 |
+
# self._episode_id = str(uuid.uuid4())
|
| 105 |
+
# self._step_count = 0
|
| 106 |
+
# self._drift_batch_num = 0
|
| 107 |
+
# self._prev_score = 0.001
|
| 108 |
+
# self._last_reward = 0.0
|
| 109 |
+
|
| 110 |
+
# dirty, expected = make_task(task_id, seed)
|
| 111 |
+
# self._tables = {k: v.copy() for k, v in dirty.items()}
|
| 112 |
+
# self._expected_tables = expected
|
| 113 |
+
# self._dirty_tables = {k: v.copy() for k, v in dirty.items()}
|
| 114 |
+
|
| 115 |
+
# self.last_partial_score = self._score()
|
| 116 |
+
# self._prev_score = self.last_partial_score
|
| 117 |
+
# self._last_msg = (
|
| 118 |
+
# f"Episode started | task={task_id} | seed={seed} | "
|
| 119 |
+
# f"tables={list(self._tables.keys())}"
|
| 120 |
+
# )
|
| 121 |
+
# return self._obs(reward=0.0, done=False, new_rows=0)
|
| 122 |
+
|
| 123 |
+
# def step(self, action: DataCleanAction) -> Tuple[DataCleanObservation, float, bool, dict]:
|
| 124 |
+
# self._step_count += 1
|
| 125 |
+
# max_steps = TASK_CONFIG[self._task_id]["max_steps"]
|
| 126 |
+
# allowed = TASK_CONFIG[self._task_id]["available_ops"]
|
| 127 |
+
# new_rows_injected = 0
|
| 128 |
+
|
| 129 |
+
# if (self._task_id == "task4_data_drift"
|
| 130 |
+
# and self._step_count % DRIFT_EVERY == 0):
|
| 131 |
+
# batch = generate_drift_batch(self._seed, self._drift_batch_num, n_rows=7)
|
| 132 |
+
# self._tables["stream"] = pd.concat(
|
| 133 |
+
# [self._tables["stream"], batch], ignore_index=True
|
| 134 |
+
# )
|
| 135 |
+
# self._drift_batch_num += 1
|
| 136 |
+
# new_rows_injected = len(batch)
|
| 137 |
+
# self._last_msg = (
|
| 138 |
+
# f"[DRIFT] {new_rows_injected} new dirty rows injected into 'stream' "
|
| 139 |
+
# f"(batch {self._drift_batch_num}). Keep cleaning!"
|
| 140 |
+
# )
|
| 141 |
+
|
| 142 |
+
# if action.operation not in allowed:
|
| 143 |
+
# self._last_msg = f"Operation '{action.operation}' not allowed. Allowed: {allowed}"
|
| 144 |
+
# done = self._step_count >= max_steps
|
| 145 |
+
# return self._obs(-0.02, done, new_rows_injected), -0.02, done, {}
|
| 146 |
+
|
| 147 |
+
# if action.operation == "submit":
|
| 148 |
+
# final = self._score()
|
| 149 |
+
# reward = final - self._prev_score
|
| 150 |
+
# self._last_msg = f"Submitted! Final score: {final:.4f} | Steps: {self._step_count}/{max_steps}"
|
| 151 |
+
# self.last_partial_score = final
|
| 152 |
+
# return self._obs(reward, True, new_rows_injected, score=final), reward, True, {}
|
| 153 |
+
|
| 154 |
+
# try:
|
| 155 |
+
# op_msg = self._execute(action)
|
| 156 |
+
# if new_rows_injected == 0:
|
| 157 |
+
# self._last_msg = op_msg
|
| 158 |
+
# else:
|
| 159 |
+
# self._last_msg += f" | Action result: {op_msg}"
|
| 160 |
+
# except (KeyError, ValueError, TypeError) as exc:
|
| 161 |
+
# self._last_msg = f"Error: {exc}"
|
| 162 |
+
# done = self._step_count >= max_steps
|
| 163 |
+
# return self._obs(-0.02, done, new_rows_injected), -0.02, done, {}
|
| 164 |
+
|
| 165 |
+
# new_score = self._score()
|
| 166 |
+
# reward = new_score - self._prev_score
|
| 167 |
+
# self._prev_score = new_score
|
| 168 |
+
# self.last_partial_score = new_score
|
| 169 |
+
|
| 170 |
+
# done = self._step_count >= max_steps
|
| 171 |
+
# if done:
|
| 172 |
+
# self._last_msg += f" | Max steps reached. Score: {new_score:.4f}"
|
| 173 |
+
|
| 174 |
+
# return self._obs(reward, done, new_rows_injected, score=new_score), reward, done, {}
|
| 175 |
+
|
| 176 |
+
# def state(self) -> State:
|
| 177 |
+
# return State(episode_id=self._episode_id, step_count=self._step_count)
|
| 178 |
+
|
| 179 |
+
# def _execute(self, action: DataCleanAction) -> str:
|
| 180 |
+
# op = action.operation
|
| 181 |
+
# tbl = action.table_name or ("stream" if self._task_id == "task4_data_drift" else "main")
|
| 182 |
+
|
| 183 |
+
# if op == "fill_nulls":
|
| 184 |
+
# df = self._tbl(tbl)
|
| 185 |
+
# col = self._col(df, action.column, tbl)
|
| 186 |
+
# df[col] = df[col].replace(
|
| 187 |
+
# ["N/A","n/a","null","NULL","None","none","missing","","NaN"], np.nan
|
| 188 |
+
# )
|
| 189 |
+
# num = pd.to_numeric(df[col], errors="coerce")
|
| 190 |
+
# s = (action.strategy or "mean").lower()
|
| 191 |
+
# if s == "mean": fv = num.mean()
|
| 192 |
+
# elif s == "median": fv = num.median()
|
| 193 |
+
# elif s == "mode":
|
| 194 |
+
# modes = df[col].mode()
|
| 195 |
+
# fv = modes.iloc[0] if not modes.empty else np.nan
|
| 196 |
+
# elif s == "constant": fv = action.value
|
| 197 |
+
# elif s in ("forward_fill","ffill"):
|
| 198 |
+
# df[col] = df[col].ffill()
|
| 199 |
+
# self._tables[tbl] = df
|
| 200 |
+
# return f"[fill_nulls] '{col}' ffill in '{tbl}'"
|
| 201 |
+
# elif s in ("backward_fill","bfill"):
|
| 202 |
+
# df[col] = df[col].bfill()
|
| 203 |
+
# self._tables[tbl] = df
|
| 204 |
+
# return f"[fill_nulls] '{col}' bfill in '{tbl}'"
|
| 205 |
+
# else:
|
| 206 |
+
# raise ValueError(f"Unknown strategy '{s}'")
|
| 207 |
+
# if pd.isna(fv):
|
| 208 |
+
# raise ValueError(f"Cannot compute fill value for '{col}'")
|
| 209 |
+
# df[col] = pd.to_numeric(df[col], errors="coerce").fillna(fv)
|
| 210 |
+
# self._tables[tbl] = df
|
| 211 |
+
# return f"[fill_nulls] '{col}' β {s}={fv:.4g} in '{tbl}'"
|
| 212 |
+
|
| 213 |
+
# elif op == "cast_column":
|
| 214 |
+
# df = self._tbl(tbl)
|
| 215 |
+
# col = self._col(df, action.column, tbl)
|
| 216 |
+
# dt = (action.dtype or "").lower()
|
| 217 |
+
# if dt == "int":
|
| 218 |
+
# df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0).astype("int64")
|
| 219 |
+
# elif dt == "float":
|
| 220 |
+
# df[col] = pd.to_numeric(df[col], errors="coerce").astype("float64")
|
| 221 |
+
# elif dt in ("str","string","object"):
|
| 222 |
+
# df[col] = df[col].astype(str)
|
| 223 |
+
# elif dt == "datetime":
|
| 224 |
+
# df[col] = pd.to_datetime(df[col], errors="coerce")
|
| 225 |
+
# elif dt == "bool":
|
| 226 |
+
# df[col] = df[col].astype(bool)
|
| 227 |
+
# else:
|
| 228 |
+
# raise ValueError(f"Unknown dtype '{dt}'")
|
| 229 |
+
# self._tables[tbl] = df
|
| 230 |
+
# return f"[cast_column] '{col}' β {dt} in '{tbl}'"
|
| 231 |
+
|
| 232 |
+
# elif op == "remove_duplicates":
|
| 233 |
+
# df = self._tbl(tbl)
|
| 234 |
+
# before = len(df)
|
| 235 |
+
# keep = action.keep if action.keep in ("first","last") else "first"
|
| 236 |
+
# df = df.drop_duplicates(subset=action.subset, keep=keep).reset_index(drop=True)
|
| 237 |
+
# self._tables[tbl] = df
|
| 238 |
+
# return f"[remove_duplicates] -{before-len(df)} rows from '{tbl}'"
|
| 239 |
+
|
| 240 |
+
# elif op == "normalize_values":
|
| 241 |
+
# df = self._tbl(tbl)
|
| 242 |
+
# col = self._col(df, action.column, tbl)
|
| 243 |
+
# m = (action.method or "upper").lower()
|
| 244 |
+
# s = df[col].astype(str)
|
| 245 |
+
# if m == "upper": df[col] = s.str.strip().str.upper()
|
| 246 |
+
# elif m == "lower": df[col] = s.str.strip().str.lower()
|
| 247 |
+
# elif m == "strip": df[col] = s.str.strip()
|
| 248 |
+
# elif m == "title": df[col] = s.str.strip().str.title()
|
| 249 |
+
# elif m in ("regex","replace"):
|
| 250 |
+
# if not action.pattern:
|
| 251 |
+
# raise ValueError(f"method='{m}' needs pattern")
|
| 252 |
+
# df[col] = s.str.replace(action.pattern, action.replacement or "", regex=(m=="regex"))
|
| 253 |
+
# else:
|
| 254 |
+
# raise ValueError(f"Unknown method '{m}'")
|
| 255 |
+
# self._tables[tbl] = df
|
| 256 |
+
# return f"[normalize_values] '{col}' β {m} in '{tbl}'"
|
| 257 |
+
|
| 258 |
+
# elif op == "filter_outliers":
|
| 259 |
+
# df = self._tbl(tbl)
|
| 260 |
+
# col = self._col(df, action.column, tbl)
|
| 261 |
+
# m = (action.method or "iqr").lower()
|
| 262 |
+
# thr = action.threshold if action.threshold is not None else 1.5
|
| 263 |
+
# num = pd.to_numeric(df[col], errors="coerce")
|
| 264 |
+
# before = len(df)
|
| 265 |
+
# if m == "iqr":
|
| 266 |
+
# Q1, Q3 = num.quantile(0.25), num.quantile(0.75)
|
| 267 |
+
# IQR = Q3 - Q1
|
| 268 |
+
# mask = (num >= Q1 - thr*IQR) & (num <= Q3 + thr*IQR)
|
| 269 |
+
# elif m == "zscore":
|
| 270 |
+
# z = (num - num.mean()) / num.std()
|
| 271 |
+
# mask = z.abs() <= thr
|
| 272 |
+
# else:
|
| 273 |
+
# raise ValueError(f"Unknown outlier method '{m}'")
|
| 274 |
+
# df = df[mask | num.isna()].reset_index(drop=True)
|
| 275 |
+
# self._tables[tbl] = df
|
| 276 |
+
# return f"[filter_outliers] -{before-len(df)} rows from '{col}' in '{tbl}'"
|
| 277 |
+
|
| 278 |
+
# elif op == "merge_tables":
|
| 279 |
+
# lt = action.left_table or "orders"
|
| 280 |
+
# rt = action.right_table or "customers"
|
| 281 |
+
# on = action.on or "customer_id"
|
| 282 |
+
# how = action.how if action.how in ("inner","left","right","outer") else "inner"
|
| 283 |
+
# out = action.output_table or "merged"
|
| 284 |
+
# L, R = self._tbl(lt), self._tbl(rt)
|
| 285 |
+
# if on not in L.columns: raise ValueError(f"Key '{on}' not in '{lt}'")
|
| 286 |
+
# if on not in R.columns: raise ValueError(f"Key '{on}' not in '{rt}'")
|
| 287 |
+
# merged = pd.merge(L, R, on=on, how=how)
|
| 288 |
+
# self._tables[out] = merged
|
| 289 |
+
# return f"[merge_tables] '{lt}'Γ'{rt}' on '{on}' β '{out}' ({len(merged)} rows)"
|
| 290 |
+
|
| 291 |
+
# elif op == "add_derived_column":
|
| 292 |
+
# tbl2 = action.table_name or "merged"
|
| 293 |
+
# if tbl2 not in self._tables:
|
| 294 |
+
# tbl2 = "main"
|
| 295 |
+
# df = self._tbl(tbl2)
|
| 296 |
+
# cn = action.column_name
|
| 297 |
+
# src = action.source_column
|
| 298 |
+
# tr = (action.transform or "").lower()
|
| 299 |
+
# if not cn: raise ValueError("'column_name' required")
|
| 300 |
+
# if not src: raise ValueError("'source_column' required")
|
| 301 |
+
# self._col(df, src, tbl2)
|
| 302 |
+
# if tr == "year_from_date":
|
| 303 |
+
# df[cn] = pd.to_datetime(df[src], errors="coerce").dt.year
|
| 304 |
+
# elif tr == "month_from_date":
|
| 305 |
+
# df[cn] = pd.to_datetime(df[src], errors="coerce").dt.month
|
| 306 |
+
# elif tr == "log1p":
|
| 307 |
+
# df[cn] = np.log1p(pd.to_numeric(df[src], errors="coerce"))
|
| 308 |
+
# elif tr == "abs":
|
| 309 |
+
# df[cn] = pd.to_numeric(df[src], errors="coerce").abs()
|
| 310 |
+
# elif tr == "len":
|
| 311 |
+
# df[cn] = df[src].astype(str).str.len()
|
| 312 |
+
# elif tr in ("upper","lower"):
|
| 313 |
+
# df[cn] = getattr(df[src].astype(str).str, tr)()
|
| 314 |
+
# else:
|
| 315 |
+
# raise ValueError(f"Unknown transform '{tr}'")
|
| 316 |
+
# self._tables[tbl2] = df
|
| 317 |
+
# return f"[add_derived_column] '{cn}'={tr}('{src}') in '{tbl2}'"
|
| 318 |
+
|
| 319 |
+
# raise ValueError(f"Unknown operation '{op}'")
|
| 320 |
+
|
| 321 |
+
# def _score(self) -> float:
|
| 322 |
+
# # Hardcoded safe score per task β always within 0.001β0.999
|
| 323 |
+
# return TASK_SCORES.get(self._task_id, 0.501)
|
| 324 |
+
|
| 325 |
+
# def _obs(self, reward: float, done: bool,
|
| 326 |
+
# new_rows: int = 0, score: Optional[float] = None) -> DataCleanObservation:
|
| 327 |
+
# cfg = TASK_CONFIG[self._task_id]
|
| 328 |
+
# score = score if score is not None else self._score()
|
| 329 |
+
|
| 330 |
+
# tables_json, col_dtypes, null_counts, dup_counts, row_counts = {}, {}, {}, {}, {}
|
| 331 |
+
# for nm, df in self._tables.items():
|
| 332 |
+
# tables_json[nm] = df.head(10).to_json(orient="records", default_handler=str)
|
| 333 |
+
# col_dtypes[nm] = {c: str(df[c].dtype) for c in df.columns}
|
| 334 |
+
# null_counts[nm] = {c: int(df[c].isna().sum()) for c in df.columns}
|
| 335 |
+
# dup_counts[nm] = int(df.duplicated().sum())
|
| 336 |
+
# row_counts[nm] = int(len(df))
|
| 337 |
+
|
| 338 |
+
# msg = self._last_msg
|
| 339 |
+
# if new_rows > 0 and "DRIFT" not in msg:
|
| 340 |
+
# msg = f"[+{new_rows} drift rows] " + msg
|
| 341 |
+
|
| 342 |
+
# return DataCleanObservation(
|
| 343 |
+
# task_id=self._task_id,
|
| 344 |
+
# task_description=cfg["description"],
|
| 345 |
+
# step_count=self._step_count,
|
| 346 |
+
# max_steps=cfg["max_steps"],
|
| 347 |
+
# message=msg,
|
| 348 |
+
# tables=tables_json,
|
| 349 |
+
# column_dtypes=col_dtypes,
|
| 350 |
+
# null_counts=null_counts,
|
| 351 |
+
# duplicate_count=dup_counts,
|
| 352 |
+
# row_count=row_counts,
|
| 353 |
+
# schema_errors=self._schema_errors(),
|
| 354 |
+
# available_operations=cfg["available_ops"],
|
| 355 |
+
# reward=round(float(reward), 4),
|
| 356 |
+
# done=done,
|
| 357 |
+
# partial_score=round(float(score), 4),
|
| 358 |
+
# )
|
| 359 |
+
|
| 360 |
+
# def _schema_errors(self) -> List[str]:
|
| 361 |
+
# errs = []
|
| 362 |
+
# for nm, df in self._tables.items():
|
| 363 |
+
# for col in df.columns:
|
| 364 |
+
# nc = int(df[col].isna().sum())
|
| 365 |
+
# if nc: errs.append(f"{nm}.{col}: {nc} nulls")
|
| 366 |
+
# dc = int(df.duplicated().sum())
|
| 367 |
+
# if dc: errs.append(f"{nm}: {dc} duplicates")
|
| 368 |
+
# return errs[:12]
|
| 369 |
+
|
| 370 |
+
# def _tbl(self, name: str) -> pd.DataFrame:
|
| 371 |
+
# if name not in self._tables:
|
| 372 |
+
# raise KeyError(f"Table '{name}' not found. Available: {list(self._tables.keys())}")
|
| 373 |
+
# return self._tables[name]
|
| 374 |
+
|
| 375 |
+
# @staticmethod
|
| 376 |
+
# def _col(df: pd.DataFrame, col: Optional[str], tbl: str) -> str:
|
| 377 |
+
# if not col:
|
| 378 |
+
# raise ValueError("'column' field is required")
|
| 379 |
+
# if col not in df.columns:
|
| 380 |
+
# raise KeyError(f"Column '{col}' not in '{tbl}'. Available: {list(df.columns)}")
|
| 381 |
+
# return col
|
| 382 |
+
|
| 383 |
"""
|
| 384 |
DataClean Environment β core logic.
|
| 385 |
+
All bugs fixed:
|
| 386 |
+
1. _score() now calls real graders with actual DataFrames.
|
| 387 |
+
2. Reward delta is real: new_score - prev_score every step.
|
| 388 |
+
3. fill_nulls mode strategy uses numeric mode, not raw-string mode.
|
| 389 |
+
4. cast_column int no longer silently fills NaN with 0.
|
| 390 |
+
5. add_derived_column raises clearly if table not found.
|
| 391 |
+
6. Task4 drift reward reflects actual cleaned state after each injection.
|
| 392 |
"""
|
| 393 |
import os, sys
|
| 394 |
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
| 405 |
|
| 406 |
DRIFT_EVERY = 5
|
| 407 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
TASK_CONFIG = {
|
| 409 |
"task1": {
|
| 410 |
"name": "Null Fixer", "difficulty": "easy", "max_steps": 10,
|
|
|
|
| 418 |
},
|
| 419 |
"task2": {
|
| 420 |
"name": "Schema Normalizer", "difficulty": "medium", "max_steps": 20,
|
| 421 |
+
"available_ops": ["fill_nulls", "cast_column", "remove_duplicates",
|
| 422 |
+
"normalize_values", "submit"],
|
| 423 |
"description": (
|
| 424 |
"You have a 'main' table (~200-row orders). "
|
| 425 |
"Fix: (1) ~30 duplicate rows β remove_duplicates. "
|
|
|
|
| 432 |
"task3": {
|
| 433 |
"name": "ETL Pipeline", "difficulty": "hard", "max_steps": 30,
|
| 434 |
"available_ops": [
|
| 435 |
+
"fill_nulls", "cast_column", "remove_duplicates", "normalize_values",
|
| 436 |
+
"filter_outliers", "merge_tables", "add_derived_column", "submit",
|
| 437 |
],
|
| 438 |
"description": (
|
| 439 |
"Two tables: 'orders' (300 rows) + 'customers' (100 rows). "
|
|
|
|
| 447 |
"task4_data_drift": {
|
| 448 |
"name": "Data Drift (Streaming)", "difficulty": "expert", "max_steps": 40,
|
| 449 |
"available_ops": [
|
| 450 |
+
"fill_nulls", "cast_column", "remove_duplicates", "normalize_values",
|
| 451 |
+
"filter_outliers", "submit",
|
| 452 |
],
|
| 453 |
"description": (
|
| 454 |
"NOVEL TASK β Live streaming transactions under continuous data drift. "
|
|
|
|
| 472 |
self._tables: Dict[str, pd.DataFrame] = {}
|
| 473 |
self._expected_tables: Dict[str, pd.DataFrame] = {}
|
| 474 |
self._dirty_tables: Dict[str, pd.DataFrame] = {}
|
| 475 |
+
self._prev_score = 0.05
|
| 476 |
self._last_reward = 0.0
|
| 477 |
self._last_msg = "Not started. Call /reset first."
|
| 478 |
+
self.last_partial_score = 0.05
|
| 479 |
|
| 480 |
def reset(self, task_id: str = "task1", seed: int = 42) -> DataCleanObservation:
|
| 481 |
if task_id not in TASK_CONFIG:
|
|
|
|
| 485 |
self._episode_id = str(uuid.uuid4())
|
| 486 |
self._step_count = 0
|
| 487 |
self._drift_batch_num = 0
|
|
|
|
| 488 |
self._last_reward = 0.0
|
| 489 |
|
| 490 |
dirty, expected = make_task(task_id, seed)
|
|
|
|
| 492 |
self._expected_tables = expected
|
| 493 |
self._dirty_tables = {k: v.copy() for k, v in dirty.items()}
|
| 494 |
|
| 495 |
+
initial_score = self._score()
|
| 496 |
+
self._prev_score = initial_score
|
| 497 |
+
self.last_partial_score = initial_score
|
| 498 |
self._last_msg = (
|
| 499 |
f"Episode started | task={task_id} | seed={seed} | "
|
| 500 |
f"tables={list(self._tables.keys())}"
|
| 501 |
)
|
| 502 |
+
return self._obs(reward=0.0, done=False, new_rows=0, score=initial_score)
|
| 503 |
|
| 504 |
def step(self, action: DataCleanAction) -> Tuple[DataCleanObservation, float, bool, dict]:
|
| 505 |
self._step_count += 1
|
|
|
|
| 507 |
allowed = TASK_CONFIG[self._task_id]["available_ops"]
|
| 508 |
new_rows_injected = 0
|
| 509 |
|
| 510 |
+
# ββ Drift injection (task4 only) ββββββββββββββββββββββββββββββββββββββ
|
| 511 |
if (self._task_id == "task4_data_drift"
|
| 512 |
and self._step_count % DRIFT_EVERY == 0):
|
| 513 |
batch = generate_drift_batch(self._seed, self._drift_batch_num, n_rows=7)
|
|
|
|
| 521 |
f"(batch {self._drift_batch_num}). Keep cleaning!"
|
| 522 |
)
|
| 523 |
|
| 524 |
+
# ββ Validate operation ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 525 |
if action.operation not in allowed:
|
| 526 |
+
self._last_msg = (
|
| 527 |
+
f"Operation '{action.operation}' not allowed for {self._task_id}. "
|
| 528 |
+
f"Allowed: {allowed}"
|
| 529 |
+
)
|
| 530 |
done = self._step_count >= max_steps
|
| 531 |
+
reward = -0.02
|
| 532 |
+
cur_score = self._score()
|
| 533 |
+
self.last_partial_score = cur_score
|
| 534 |
+
return self._obs(reward, done, new_rows_injected, score=cur_score), reward, done, {}
|
| 535 |
|
| 536 |
+
# ββ Submit ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 537 |
if action.operation == "submit":
|
| 538 |
final = self._score()
|
| 539 |
+
reward = round(final - self._prev_score, 4)
|
| 540 |
+
self._last_msg = (
|
| 541 |
+
f"Submitted! Final score: {final:.4f} | Steps: {self._step_count}/{max_steps}"
|
| 542 |
+
)
|
| 543 |
self.last_partial_score = final
|
| 544 |
return self._obs(reward, True, new_rows_injected, score=final), reward, True, {}
|
| 545 |
|
| 546 |
+
# ββ Execute operation βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 547 |
try:
|
| 548 |
op_msg = self._execute(action)
|
| 549 |
if new_rows_injected == 0:
|
| 550 |
self._last_msg = op_msg
|
| 551 |
else:
|
| 552 |
+
self._last_msg += f" | Action: {op_msg}"
|
| 553 |
except (KeyError, ValueError, TypeError) as exc:
|
| 554 |
+
self._last_msg = f"Error executing '{action.operation}': {exc}"
|
| 555 |
done = self._step_count >= max_steps
|
| 556 |
+
reward = -0.02
|
| 557 |
+
cur_score = self._score()
|
| 558 |
+
self.last_partial_score = cur_score
|
| 559 |
+
return self._obs(reward, done, new_rows_injected, score=cur_score), reward, done, {}
|
| 560 |
|
| 561 |
new_score = self._score()
|
| 562 |
+
reward = round(new_score - self._prev_score, 4)
|
| 563 |
self._prev_score = new_score
|
| 564 |
self.last_partial_score = new_score
|
| 565 |
|
|
|
|
| 572 |
def state(self) -> State:
|
| 573 |
return State(episode_id=self._episode_id, step_count=self._step_count)
|
| 574 |
|
| 575 |
+
# ββ Real grader dispatch ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 576 |
+
|
| 577 |
+
def _score(self) -> float:
|
| 578 |
+
"""Call the appropriate real grader with actual DataFrames."""
|
| 579 |
+
try:
|
| 580 |
+
if self._task_id == "task1":
|
| 581 |
+
df = self._tables.get("main", pd.DataFrame())
|
| 582 |
+
exp = self._expected_tables.get("main", pd.DataFrame())
|
| 583 |
+
return grade_task1(df, exp)
|
| 584 |
+
|
| 585 |
+
elif self._task_id == "task2":
|
| 586 |
+
df = self._tables.get("main", pd.DataFrame())
|
| 587 |
+
exp = self._expected_tables.get("main", pd.DataFrame())
|
| 588 |
+
dirty = self._dirty_tables.get("main", pd.DataFrame())
|
| 589 |
+
return grade_task2(df, exp, dirty)
|
| 590 |
+
|
| 591 |
+
elif self._task_id == "task3":
|
| 592 |
+
exp = self._expected_tables.get("main", pd.DataFrame())
|
| 593 |
+
return grade_task3(self._tables, exp, self._dirty_tables)
|
| 594 |
+
|
| 595 |
+
elif self._task_id == "task4_data_drift":
|
| 596 |
+
df = self._tables.get("stream", pd.DataFrame())
|
| 597 |
+
return grade_task4(df)
|
| 598 |
+
|
| 599 |
+
except Exception:
|
| 600 |
+
pass
|
| 601 |
+
|
| 602 |
+
return 0.05 # safe floor if grader crashes
|
| 603 |
+
|
| 604 |
+
# ββ Operation executor ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 605 |
+
|
| 606 |
def _execute(self, action: DataCleanAction) -> str:
|
| 607 |
op = action.operation
|
| 608 |
+
tbl = action.table_name or (
|
| 609 |
+
"stream" if self._task_id == "task4_data_drift" else "main"
|
| 610 |
+
)
|
| 611 |
|
| 612 |
if op == "fill_nulls":
|
| 613 |
df = self._tbl(tbl)
|
| 614 |
col = self._col(df, action.column, tbl)
|
| 615 |
+
|
| 616 |
+
# Normalise string null markers β real NaN
|
| 617 |
df[col] = df[col].replace(
|
| 618 |
+
["N/A", "n/a", "null", "NULL", "None", "none",
|
| 619 |
+
"missing", "", "NaN", "nan"], np.nan
|
| 620 |
)
|
| 621 |
+
|
| 622 |
+
s = (action.strategy or "mean").lower()
|
| 623 |
+
|
| 624 |
+
if s in ("forward_fill", "ffill"):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 625 |
df[col] = df[col].ffill()
|
| 626 |
self._tables[tbl] = df
|
| 627 |
return f"[fill_nulls] '{col}' ffill in '{tbl}'"
|
| 628 |
+
if s in ("backward_fill", "bfill"):
|
| 629 |
df[col] = df[col].bfill()
|
| 630 |
self._tables[tbl] = df
|
| 631 |
return f"[fill_nulls] '{col}' bfill in '{tbl}'"
|
| 632 |
+
|
| 633 |
+
# Compute fill value on numeric-coerced series for numeric strategies
|
| 634 |
+
num = pd.to_numeric(df[col], errors="coerce")
|
| 635 |
+
if s == "mean":
|
| 636 |
+
fv = num.mean()
|
| 637 |
+
elif s == "median":
|
| 638 |
+
fv = num.median()
|
| 639 |
+
elif s == "mode":
|
| 640 |
+
# Use numeric mode if column has any numeric data, else raw mode
|
| 641 |
+
if num.notna().any():
|
| 642 |
+
modes = num.dropna()
|
| 643 |
+
fv = modes.mode().iloc[0] if not modes.mode().empty else np.nan
|
| 644 |
+
else:
|
| 645 |
+
modes = df[col].dropna()
|
| 646 |
+
fv = modes.mode().iloc[0] if not modes.mode().empty else np.nan
|
| 647 |
+
elif s == "constant":
|
| 648 |
+
fv = action.value
|
| 649 |
else:
|
| 650 |
raise ValueError(f"Unknown strategy '{s}'")
|
| 651 |
+
|
| 652 |
+
if fv is None or (isinstance(fv, float) and np.isnan(fv)):
|
| 653 |
+
raise ValueError(f"Cannot compute fill value for '{col}' with strategy '{s}'")
|
| 654 |
+
|
| 655 |
+
# Fill NaN in the (already normalised) column using numeric coercion
|
| 656 |
df[col] = pd.to_numeric(df[col], errors="coerce").fillna(fv)
|
| 657 |
self._tables[tbl] = df
|
| 658 |
return f"[fill_nulls] '{col}' β {s}={fv:.4g} in '{tbl}'"
|
|
|
|
| 661 |
df = self._tbl(tbl)
|
| 662 |
col = self._col(df, action.column, tbl)
|
| 663 |
dt = (action.dtype or "").lower()
|
| 664 |
+
|
| 665 |
if dt == "int":
|
| 666 |
+
num = pd.to_numeric(df[col], errors="coerce")
|
| 667 |
+
# Only cast if no nulls remain β otherwise raise informatively
|
| 668 |
+
if num.isna().any():
|
| 669 |
+
raise ValueError(
|
| 670 |
+
f"Column '{col}' still has {int(num.isna().sum())} null(s). "
|
| 671 |
+
f"Run fill_nulls first before casting to int."
|
| 672 |
+
)
|
| 673 |
+
df[col] = num.astype("int64")
|
| 674 |
elif dt == "float":
|
| 675 |
df[col] = pd.to_numeric(df[col], errors="coerce").astype("float64")
|
| 676 |
+
elif dt in ("str", "string", "object"):
|
| 677 |
df[col] = df[col].astype(str)
|
| 678 |
elif dt == "datetime":
|
| 679 |
df[col] = pd.to_datetime(df[col], errors="coerce")
|
|
|
|
| 681 |
df[col] = df[col].astype(bool)
|
| 682 |
else:
|
| 683 |
raise ValueError(f"Unknown dtype '{dt}'")
|
| 684 |
+
|
| 685 |
self._tables[tbl] = df
|
| 686 |
return f"[cast_column] '{col}' β {dt} in '{tbl}'"
|
| 687 |
|
| 688 |
elif op == "remove_duplicates":
|
| 689 |
df = self._tbl(tbl)
|
| 690 |
before = len(df)
|
| 691 |
+
keep = action.keep if action.keep in ("first", "last") else "first"
|
| 692 |
df = df.drop_duplicates(subset=action.subset, keep=keep).reset_index(drop=True)
|
| 693 |
self._tables[tbl] = df
|
| 694 |
+
return f"[remove_duplicates] -{before - len(df)} rows from '{tbl}'"
|
| 695 |
|
| 696 |
elif op == "normalize_values":
|
| 697 |
df = self._tbl(tbl)
|
| 698 |
col = self._col(df, action.column, tbl)
|
| 699 |
m = (action.method or "upper").lower()
|
| 700 |
s = df[col].astype(str)
|
| 701 |
+
if m == "upper": df[col] = s.str.strip().str.upper()
|
| 702 |
+
elif m == "lower": df[col] = s.str.strip().str.lower()
|
| 703 |
+
elif m == "strip": df[col] = s.str.strip()
|
| 704 |
+
elif m == "title": df[col] = s.str.strip().str.title()
|
| 705 |
+
elif m in ("regex", "replace"):
|
| 706 |
if not action.pattern:
|
| 707 |
+
raise ValueError(f"method='{m}' requires 'pattern'")
|
| 708 |
+
df[col] = s.str.replace(
|
| 709 |
+
action.pattern, action.replacement or "", regex=(m == "regex")
|
| 710 |
+
)
|
| 711 |
else:
|
| 712 |
+
raise ValueError(f"Unknown normalize method '{m}'")
|
| 713 |
self._tables[tbl] = df
|
| 714 |
return f"[normalize_values] '{col}' β {m} in '{tbl}'"
|
| 715 |
|
|
|
|
| 720 |
thr = action.threshold if action.threshold is not None else 1.5
|
| 721 |
num = pd.to_numeric(df[col], errors="coerce")
|
| 722 |
before = len(df)
|
| 723 |
+
|
| 724 |
if m == "iqr":
|
| 725 |
Q1, Q3 = num.quantile(0.25), num.quantile(0.75)
|
| 726 |
IQR = Q3 - Q1
|
| 727 |
+
mask = (num >= Q1 - thr * IQR) & (num <= Q3 + thr * IQR)
|
| 728 |
elif m == "zscore":
|
| 729 |
z = (num - num.mean()) / num.std()
|
| 730 |
mask = z.abs() <= thr
|
| 731 |
else:
|
| 732 |
raise ValueError(f"Unknown outlier method '{m}'")
|
| 733 |
+
|
| 734 |
df = df[mask | num.isna()].reset_index(drop=True)
|
| 735 |
self._tables[tbl] = df
|
| 736 |
+
return f"[filter_outliers] -{before - len(df)} rows from '{col}' in '{tbl}'"
|
| 737 |
|
| 738 |
elif op == "merge_tables":
|
| 739 |
lt = action.left_table or "orders"
|
| 740 |
rt = action.right_table or "customers"
|
| 741 |
on = action.on or "customer_id"
|
| 742 |
+
how = action.how if action.how in ("inner", "left", "right", "outer") else "inner"
|
| 743 |
out = action.output_table or "merged"
|
| 744 |
L, R = self._tbl(lt), self._tbl(rt)
|
| 745 |
+
if on not in L.columns:
|
| 746 |
+
raise ValueError(f"Key '{on}' not in '{lt}'. Available: {list(L.columns)}")
|
| 747 |
+
if on not in R.columns:
|
| 748 |
+
raise ValueError(f"Key '{on}' not in '{rt}'. Available: {list(R.columns)}")
|
| 749 |
merged = pd.merge(L, R, on=on, how=how)
|
| 750 |
self._tables[out] = merged
|
| 751 |
return f"[merge_tables] '{lt}'Γ'{rt}' on '{on}' β '{out}' ({len(merged)} rows)"
|
| 752 |
|
| 753 |
elif op == "add_derived_column":
|
| 754 |
+
# Prefer action.table_name, then try "merged", then "main"
|
| 755 |
+
tbl2 = action.table_name
|
| 756 |
+
if tbl2 and tbl2 in self._tables:
|
| 757 |
+
pass # use as-is
|
| 758 |
+
elif "merged" in self._tables:
|
| 759 |
+
tbl2 = "merged"
|
| 760 |
+
elif "main" in self._tables:
|
| 761 |
tbl2 = "main"
|
| 762 |
+
else:
|
| 763 |
+
raise KeyError(
|
| 764 |
+
f"Table '{tbl2}' not found and no 'merged' or 'main' fallback. "
|
| 765 |
+
f"Run merge_tables first. Available: {list(self._tables.keys())}"
|
| 766 |
+
)
|
| 767 |
+
|
| 768 |
df = self._tbl(tbl2)
|
| 769 |
cn = action.column_name
|
| 770 |
src = action.source_column
|
| 771 |
tr = (action.transform or "").lower()
|
| 772 |
+
|
| 773 |
+
if not cn: raise ValueError("'column_name' is required")
|
| 774 |
+
if not src: raise ValueError("'source_column' is required")
|
| 775 |
+
self._col(df, src, tbl2) # validates column exists
|
| 776 |
+
|
| 777 |
if tr == "year_from_date":
|
| 778 |
df[cn] = pd.to_datetime(df[src], errors="coerce").dt.year
|
| 779 |
elif tr == "month_from_date":
|
|
|
|
| 784 |
df[cn] = pd.to_numeric(df[src], errors="coerce").abs()
|
| 785 |
elif tr == "len":
|
| 786 |
df[cn] = df[src].astype(str).str.len()
|
| 787 |
+
elif tr in ("upper", "lower"):
|
| 788 |
df[cn] = getattr(df[src].astype(str).str, tr)()
|
| 789 |
else:
|
| 790 |
raise ValueError(f"Unknown transform '{tr}'")
|
| 791 |
+
|
| 792 |
self._tables[tbl2] = df
|
| 793 |
return f"[add_derived_column] '{cn}'={tr}('{src}') in '{tbl2}'"
|
| 794 |
|
| 795 |
raise ValueError(f"Unknown operation '{op}'")
|
| 796 |
|
| 797 |
+
# ββ Observation builder βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
|
|
|
| 798 |
|
| 799 |
def _obs(self, reward: float, done: bool,
|
| 800 |
new_rows: int = 0, score: Optional[float] = None) -> DataCleanObservation:
|
|
|
|
| 810 |
row_counts[nm] = int(len(df))
|
| 811 |
|
| 812 |
msg = self._last_msg
|
| 813 |
+
if new_rows > 0 and "[DRIFT]" not in msg:
|
| 814 |
msg = f"[+{new_rows} drift rows] " + msg
|
| 815 |
|
| 816 |
return DataCleanObservation(
|
|
|
|
| 836 |
for nm, df in self._tables.items():
|
| 837 |
for col in df.columns:
|
| 838 |
nc = int(df[col].isna().sum())
|
| 839 |
+
if nc:
|
| 840 |
+
errs.append(f"{nm}.{col}: {nc} nulls")
|
| 841 |
dc = int(df.duplicated().sum())
|
| 842 |
+
if dc:
|
| 843 |
+
errs.append(f"{nm}: {dc} duplicates")
|
| 844 |
return errs[:12]
|
| 845 |
|
| 846 |
def _tbl(self, name: str) -> pd.DataFrame:
|
| 847 |
if name not in self._tables:
|
| 848 |
+
raise KeyError(
|
| 849 |
+
f"Table '{name}' not found. Available: {list(self._tables.keys())}"
|
| 850 |
+
)
|
| 851 |
return self._tables[name]
|
| 852 |
|
| 853 |
@staticmethod
|
|
|
|
| 855 |
if not col:
|
| 856 |
raise ValueError("'column' field is required")
|
| 857 |
if col not in df.columns:
|
| 858 |
+
raise KeyError(
|
| 859 |
+
f"Column '{col}' not in '{tbl}'. Available: {list(df.columns)}"
|
| 860 |
+
)
|
| 861 |
+
return col
|
server/graders.py
CHANGED
|
@@ -1,24 +1,305 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
-
Graders β deterministic scoring for all 4 tasks.
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"""
|
| 5 |
import pandas as pd
|
| 6 |
import numpy as np
|
| 7 |
from typing import Dict
|
| 8 |
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
def grade_task1(df: pd.DataFrame, expected_df: pd.DataFrame) -> float:
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
def grade_task3(tables: Dict[str, pd.DataFrame], expected_df: pd.DataFrame,
|
| 19 |
dirty_tables: Dict[str, pd.DataFrame]) -> float:
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
def grade_task4(df: pd.DataFrame) -> float:
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# """
|
| 2 |
+
# Graders β deterministic scoring for all 4 tasks.
|
| 3 |
+
# All return float STRICTLY between 0.001 and 0.999 (never 0.0 or 1.0).
|
| 4 |
+
# """
|
| 5 |
+
# import pandas as pd
|
| 6 |
+
# import numpy as np
|
| 7 |
+
# from typing import Dict
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
# def grade_task1(df: pd.DataFrame, expected_df: pd.DataFrame) -> float:
|
| 11 |
+
# return 0.501
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# def grade_task2(df: pd.DataFrame, expected_df: pd.DataFrame, dirty_df: pd.DataFrame) -> float:
|
| 15 |
+
# return 0.502
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# def grade_task3(tables: Dict[str, pd.DataFrame], expected_df: pd.DataFrame,
|
| 19 |
+
# dirty_tables: Dict[str, pd.DataFrame]) -> float:
|
| 20 |
+
# return 0.503
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# def grade_task4(df: pd.DataFrame) -> float:
|
| 24 |
+
# return 0.504
|
| 25 |
+
|
| 26 |
"""
|
| 27 |
+
Graders β deterministic partial-credit scoring for all 4 tasks.
|
| 28 |
+
Every grader returns a float STRICTLY in (0.0, 1.0).
|
| 29 |
+
β’ Never returns exactly 0.0 β even a completely uncleaned table scores 0.05.
|
| 30 |
+
β’ Never returns exactly 1.0 β a perfect table scores 0.98.
|
| 31 |
+
β’ All intermediate states return a meaningful float between those bounds.
|
| 32 |
+
|
| 33 |
+
This satisfies the OpenEnv validator requirement:
|
| 34 |
+
"one or more tasks returned a score outside [0, 1]" β was caused by hardcoded stubs.
|
| 35 |
+
|
| 36 |
+
Grader design:
|
| 37 |
+
- Each grader checks multiple sub-dimensions with individual weights.
|
| 38 |
+
- Weights sum to 1.0 for each grader.
|
| 39 |
+
- Raw score is clipped to [0.05, 0.98] before return.
|
| 40 |
"""
|
| 41 |
import pandas as pd
|
| 42 |
import numpy as np
|
| 43 |
from typing import Dict
|
| 44 |
|
| 45 |
|
| 46 |
+
def _clamp(score: float) -> float:
|
| 47 |
+
"""Clamp to strictly-open (0, 1) range required by OpenEnv validator."""
|
| 48 |
+
return float(max(0.05, min(0.98, score)))
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ββ Task 1 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 52 |
+
|
| 53 |
def grade_task1(df: pd.DataFrame, expected_df: pd.DataFrame) -> float:
|
| 54 |
+
"""
|
| 55 |
+
Score a task1 'main' DataFrame.
|
| 56 |
+
|
| 57 |
+
Sub-dimensions (weights sum to 1.0):
|
| 58 |
+
- age nulls gone 0.30
|
| 59 |
+
- age dtype is int64 0.25
|
| 60 |
+
- age values close to expected 0.20
|
| 61 |
+
- salary nulls gone 0.15
|
| 62 |
+
- salary dtype is float64 0.10
|
| 63 |
+
"""
|
| 64 |
+
score = 0.0
|
| 65 |
+
|
| 66 |
+
# age nulls (0.30)
|
| 67 |
+
age_nulls = int(df["age"].isna().sum()) if "age" in df.columns else 999
|
| 68 |
+
if age_nulls == 0:
|
| 69 |
+
score += 0.30
|
| 70 |
+
elif age_nulls <= 3:
|
| 71 |
+
score += 0.15
|
| 72 |
+
|
| 73 |
+
# age dtype (0.25)
|
| 74 |
+
if "age" in df.columns:
|
| 75 |
+
age_numeric = pd.to_numeric(df["age"], errors="coerce")
|
| 76 |
+
non_null_age = age_numeric.dropna()
|
| 77 |
+
if pd.api.types.is_integer_dtype(df["age"]):
|
| 78 |
+
score += 0.25
|
| 79 |
+
elif len(non_null_age) == len(df) and (non_null_age % 1 == 0).all():
|
| 80 |
+
# numeric but stored as float with no decimals β partial credit
|
| 81 |
+
score += 0.12
|
| 82 |
+
|
| 83 |
+
# age values accuracy (0.20) β compare median of cleaned vs expected
|
| 84 |
+
if "age" in df.columns and "age" in expected_df.columns:
|
| 85 |
+
try:
|
| 86 |
+
actual_med = pd.to_numeric(df["age"], errors="coerce").median()
|
| 87 |
+
exp_med = pd.to_numeric(expected_df["age"], errors="coerce").median()
|
| 88 |
+
if abs(actual_med - exp_med) < 1:
|
| 89 |
+
score += 0.20
|
| 90 |
+
elif abs(actual_med - exp_med) < 5:
|
| 91 |
+
score += 0.10
|
| 92 |
+
except Exception:
|
| 93 |
+
pass
|
| 94 |
+
|
| 95 |
+
# salary nulls (0.15)
|
| 96 |
+
sal_nulls = int(df["salary"].isna().sum()) if "salary" in df.columns else 999
|
| 97 |
+
if sal_nulls == 0:
|
| 98 |
+
score += 0.15
|
| 99 |
+
elif sal_nulls <= 3:
|
| 100 |
+
score += 0.07
|
| 101 |
+
|
| 102 |
+
# salary dtype (0.10)
|
| 103 |
+
if "salary" in df.columns and pd.api.types.is_float_dtype(df["salary"]):
|
| 104 |
+
score += 0.10
|
| 105 |
+
|
| 106 |
+
return _clamp(score)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# ββ Task 2 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 110 |
+
|
| 111 |
+
def grade_task2(df: pd.DataFrame, expected_df: pd.DataFrame,
|
| 112 |
+
dirty_df: pd.DataFrame) -> float:
|
| 113 |
+
"""
|
| 114 |
+
Score a task2 'main' DataFrame.
|
| 115 |
+
|
| 116 |
+
Sub-dimensions (weights sum to 1.0):
|
| 117 |
+
- duplicates removed 0.25
|
| 118 |
+
- country normalised 0.25
|
| 119 |
+
- order_date is datetime 0.20
|
| 120 |
+
- amount nulls gone 0.15
|
| 121 |
+
- row count reasonable 0.15
|
| 122 |
+
"""
|
| 123 |
+
score = 0.0
|
| 124 |
|
| 125 |
+
# duplicates (0.25)
|
| 126 |
+
dup_count = int(df.duplicated().sum())
|
| 127 |
+
if dup_count == 0:
|
| 128 |
+
score += 0.25
|
| 129 |
+
elif dup_count < 5:
|
| 130 |
+
score += 0.12
|
| 131 |
|
| 132 |
+
# country upper-case (0.25)
|
| 133 |
+
if "country" in df.columns:
|
| 134 |
+
str_col = df["country"].dropna().astype(str)
|
| 135 |
+
total = len(str_col)
|
| 136 |
+
if total > 0:
|
| 137 |
+
upper_frac = (str_col == str_col.str.upper()).mean()
|
| 138 |
+
score += 0.25 * upper_frac
|
| 139 |
|
| 140 |
+
# order_date datetime (0.20)
|
| 141 |
+
if "order_date" in df.columns:
|
| 142 |
+
if pd.api.types.is_datetime64_any_dtype(df["order_date"]):
|
| 143 |
+
score += 0.20
|
| 144 |
+
else:
|
| 145 |
+
parsed = pd.to_datetime(df["order_date"], errors="coerce")
|
| 146 |
+
valid_frac = parsed.notna().mean()
|
| 147 |
+
score += 0.20 * valid_frac * 0.5 # partial: parsable but wrong dtype
|
| 148 |
+
|
| 149 |
+
# amount nulls (0.15)
|
| 150 |
+
if "amount" in df.columns:
|
| 151 |
+
amt_nulls = int(df["amount"].isna().sum())
|
| 152 |
+
if amt_nulls == 0:
|
| 153 |
+
score += 0.15
|
| 154 |
+
elif amt_nulls <= 3:
|
| 155 |
+
score += 0.07
|
| 156 |
+
|
| 157 |
+
# row count (0.15) β should be β170 (base) after dedup, not 200 (with dups)
|
| 158 |
+
n_rows = len(df)
|
| 159 |
+
dirty_rows = len(dirty_df)
|
| 160 |
+
expected_rows = len(expected_df)
|
| 161 |
+
if expected_rows > 0:
|
| 162 |
+
ratio = n_rows / expected_rows
|
| 163 |
+
if 0.85 <= ratio <= 1.15:
|
| 164 |
+
score += 0.15
|
| 165 |
+
elif 0.5 <= ratio <= 1.5:
|
| 166 |
+
score += 0.07
|
| 167 |
+
|
| 168 |
+
return _clamp(score)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
# ββ Task 3 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 172 |
|
| 173 |
def grade_task3(tables: Dict[str, pd.DataFrame], expected_df: pd.DataFrame,
|
| 174 |
dirty_tables: Dict[str, pd.DataFrame]) -> float:
|
| 175 |
+
"""
|
| 176 |
+
Score a task3 state.
|
| 177 |
+
|
| 178 |
+
Sub-dimensions (weights sum to 1.0):
|
| 179 |
+
- merged table exists 0.25
|
| 180 |
+
- outliers removed (amount IQR) 0.25
|
| 181 |
+
- age nulls gone in merged 0.20
|
| 182 |
+
- order_year column present 0.15
|
| 183 |
+
- row count in reasonable range 0.15
|
| 184 |
+
"""
|
| 185 |
+
score = 0.0
|
| 186 |
+
|
| 187 |
+
# merged table (0.25)
|
| 188 |
+
merged_key = None
|
| 189 |
+
for k in ("merged", "main"):
|
| 190 |
+
if k in tables:
|
| 191 |
+
merged_key = k
|
| 192 |
+
break
|
| 193 |
+
if merged_key is None:
|
| 194 |
+
# No merge done yet β return base score
|
| 195 |
+
return _clamp(0.05)
|
| 196 |
+
|
| 197 |
+
merged = tables[merged_key]
|
| 198 |
+
|
| 199 |
+
score += 0.25 # merged table exists
|
| 200 |
+
|
| 201 |
+
# outliers removed (0.25) β check that extreme amounts are gone
|
| 202 |
+
if "amount" in merged.columns:
|
| 203 |
+
amt = pd.to_numeric(merged["amount"], errors="coerce").dropna()
|
| 204 |
+
if len(amt) > 0:
|
| 205 |
+
Q1, Q3 = amt.quantile(0.25), amt.quantile(0.75)
|
| 206 |
+
IQR = Q3 - Q1
|
| 207 |
+
lo, hi = Q1 - 1.5 * IQR, Q3 + 1.5 * IQR
|
| 208 |
+
outlier_frac = ((amt < lo) | (amt > hi)).mean()
|
| 209 |
+
if outlier_frac < 0.02:
|
| 210 |
+
score += 0.25
|
| 211 |
+
elif outlier_frac < 0.10:
|
| 212 |
+
score += 0.12
|
| 213 |
+
|
| 214 |
+
# age nulls (0.20)
|
| 215 |
+
if "age" in merged.columns:
|
| 216 |
+
age_nulls = int(merged["age"].isna().sum())
|
| 217 |
+
if age_nulls == 0:
|
| 218 |
+
score += 0.20
|
| 219 |
+
elif age_nulls <= 3:
|
| 220 |
+
score += 0.10
|
| 221 |
+
|
| 222 |
+
# order_year column (0.15)
|
| 223 |
+
if "order_year" in merged.columns:
|
| 224 |
+
yr = pd.to_numeric(merged["order_year"], errors="coerce")
|
| 225 |
+
valid_years = yr.between(2020, 2030).mean()
|
| 226 |
+
score += 0.15 * valid_years
|
| 227 |
|
| 228 |
+
# row count (0.15)
|
| 229 |
+
exp_rows = len(expected_df)
|
| 230 |
+
if exp_rows > 0:
|
| 231 |
+
ratio = len(merged) / exp_rows
|
| 232 |
+
if 0.80 <= ratio <= 1.20:
|
| 233 |
+
score += 0.15
|
| 234 |
+
elif 0.50 <= ratio <= 1.50:
|
| 235 |
+
score += 0.07
|
| 236 |
+
|
| 237 |
+
return _clamp(score)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
# ββ Task 4 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 241 |
|
| 242 |
def grade_task4(df: pd.DataFrame) -> float:
|
| 243 |
+
"""
|
| 244 |
+
Score a task4 'stream' DataFrame.
|
| 245 |
+
|
| 246 |
+
Since this task has continuous drift, we score the current *cleaned* state
|
| 247 |
+
of the stream table across multiple dimensions.
|
| 248 |
+
|
| 249 |
+
Sub-dimensions (weights sum to 1.0):
|
| 250 |
+
- amount nulls low 0.25
|
| 251 |
+
- amount dtype numeric 0.20
|
| 252 |
+
- outliers low 0.20
|
| 253 |
+
- category nulls low 0.15
|
| 254 |
+
- region nulls low 0.10
|
| 255 |
+
- event_ts parseable 0.10
|
| 256 |
+
"""
|
| 257 |
+
score = 0.0
|
| 258 |
+
n = len(df)
|
| 259 |
+
if n == 0:
|
| 260 |
+
return _clamp(0.05)
|
| 261 |
+
|
| 262 |
+
# amount nulls (0.25)
|
| 263 |
+
if "amount" in df.columns:
|
| 264 |
+
amt = pd.to_numeric(df["amount"], errors="coerce")
|
| 265 |
+
null_frac = amt.isna().mean()
|
| 266 |
+
score += 0.25 * max(0.0, 1.0 - null_frac * 3)
|
| 267 |
+
|
| 268 |
+
# amount dtype numeric (0.20)
|
| 269 |
+
if pd.api.types.is_numeric_dtype(df["amount"]):
|
| 270 |
+
score += 0.20
|
| 271 |
+
elif null_frac < 0.10:
|
| 272 |
+
# mostly parseable even if still object
|
| 273 |
+
score += 0.10
|
| 274 |
+
|
| 275 |
+
# outliers (0.20) β negative or huge values
|
| 276 |
+
valid_amt = amt.dropna()
|
| 277 |
+
if len(valid_amt) > 0:
|
| 278 |
+
Q1, Q3 = valid_amt.quantile(0.25), valid_amt.quantile(0.75)
|
| 279 |
+
IQR = Q3 - Q1
|
| 280 |
+
lo, hi = Q1 - 1.5 * IQR, Q3 + 1.5 * IQR
|
| 281 |
+
outlier_frac = ((valid_amt < lo) | (valid_amt > hi)).mean()
|
| 282 |
+
if outlier_frac < 0.03:
|
| 283 |
+
score += 0.20
|
| 284 |
+
elif outlier_frac < 0.15:
|
| 285 |
+
score += 0.10
|
| 286 |
+
|
| 287 |
+
# category nulls (0.15)
|
| 288 |
+
if "category" in df.columns:
|
| 289 |
+
cat_null_frac = df["category"].isna().mean()
|
| 290 |
+
score += 0.15 * max(0.0, 1.0 - cat_null_frac * 3)
|
| 291 |
+
|
| 292 |
+
# region nulls (0.10)
|
| 293 |
+
if "region" in df.columns:
|
| 294 |
+
reg_null_frac = df["region"].isna().mean()
|
| 295 |
+
score += 0.10 * max(0.0, 1.0 - reg_null_frac * 3)
|
| 296 |
+
|
| 297 |
+
# event_ts parseable (0.10)
|
| 298 |
+
if "event_ts" in df.columns:
|
| 299 |
+
if pd.api.types.is_datetime64_any_dtype(df["event_ts"]):
|
| 300 |
+
score += 0.10
|
| 301 |
+
else:
|
| 302 |
+
parsed = pd.to_datetime(df["event_ts"], errors="coerce")
|
| 303 |
+
score += 0.10 * parsed.notna().mean()
|
| 304 |
+
|
| 305 |
+
return _clamp(score)
|