Aswini-Kumar commited on
Commit
c77741a
Β·
verified Β·
1 Parent(s): cf67c72

Sync server/grader.py

Browse files
Files changed (1) hide show
  1. server/grader.py +128 -51
server/grader.py CHANGED
@@ -2,17 +2,20 @@
2
  Grader for DataWranglerEnv.
3
 
4
  Multi-dimensional scoring system that compares the agent's cleaned dataset
5
- against the ground truth. Produces deterministic scores in [0.0, 1.0].
6
 
7
  Dimensions:
8
- - Missing values fixed (25%)
9
- - Duplicates removed (20%)
10
- - Type correctness (20%)
11
- - Value accuracy (25%)
12
- - Data preservation (10%)
 
 
 
13
  """
14
 
15
- from typing import Any, Dict, Tuple
16
 
17
  import numpy as np
18
  import pandas as pd
@@ -24,6 +27,9 @@ def compute_score(
24
  clean_df: pd.DataFrame,
25
  original_dirty_df: pd.DataFrame,
26
  issue_manifest: Dict[str, Any],
 
 
 
27
  ) -> Tuple[float, Dict[str, float]]:
28
  """Compute the composite data quality score.
29
 
@@ -33,13 +39,18 @@ def compute_score(
33
  clean_df: The ground truth clean dataset
34
  original_dirty_df: The very first dirty state (for reference)
35
  issue_manifest: Manifest of all injected issues
 
 
 
36
 
37
  Returns:
38
  Tuple of (composite_score, dimension_scores_dict)
39
  """
40
  scores = {}
 
 
41
 
42
- # ── Dimension 1: Missing Values Fixed (25%) ──────────────────────────
43
  original_missing = original_dirty_df.isnull().sum().sum()
44
  current_missing = current_df.isnull().sum().sum()
45
  target_missing = clean_df.isnull().sum().sum()
@@ -51,7 +62,7 @@ def compute_score(
51
  else:
52
  scores["missing_fixed"] = 1.0
53
 
54
- # ── Dimension 2: Duplicates Removed (20%) ─────────────────────────────
55
  original_dupes = original_dirty_df.duplicated().sum()
56
  current_dupes = current_df.duplicated().sum()
57
  target_dupes = clean_df.duplicated().sum()
@@ -63,7 +74,7 @@ def compute_score(
63
  else:
64
  scores["duplicates_removed"] = 1.0
65
 
66
- # ── Dimension 3: Type Correctness (20%) ───────────────────────────────
67
  type_matches = 0
68
  type_total = len(clean_df.columns)
69
 
@@ -73,7 +84,6 @@ def compute_score(
73
  clean_dtype = clean_df[col].dtype
74
  current_dtype = current_df[col].dtype
75
 
76
- # Allow some flexibility in type matching
77
  if clean_dtype == current_dtype:
78
  type_matches += 1
79
  elif pd.api.types.is_numeric_dtype(clean_dtype) and pd.api.types.is_numeric_dtype(current_dtype):
@@ -85,14 +95,9 @@ def compute_score(
85
 
86
  scores["type_correctness"] = type_matches / type_total if type_total > 0 else 1.0
87
 
88
- # ── Dimension 4: Value Accuracy (25%) ─────────────────────────────────
89
- # Compare cell-by-cell against the clean dataset
90
- # We align rows by index where possible
91
  try:
92
- # Get the minimum row count for comparison
93
  min_rows = min(len(current_df), len(clean_df))
94
- min_cols = min(len(current_df.columns), len(clean_df.columns))
95
-
96
  common_cols = [c for c in clean_df.columns if c in current_df.columns]
97
  if common_cols and min_rows > 0:
98
  clean_subset = clean_df[common_cols].head(min_rows).reset_index(drop=True)
@@ -110,7 +115,6 @@ def compute_score(
110
  except (KeyError, IndexError):
111
  continue
112
 
113
- # Handle NaN comparison
114
  if pd.isna(clean_val) and pd.isna(current_val):
115
  matching_cells += 1
116
  elif pd.isna(clean_val) or pd.isna(current_val):
@@ -118,12 +122,11 @@ def compute_score(
118
  elif str(clean_val).strip().lower() == str(current_val).strip().lower():
119
  matching_cells += 1
120
  else:
121
- # Partial match for numeric values (within 1% tolerance)
122
  try:
123
  cv = float(clean_val)
124
  av = float(current_val)
125
  if cv != 0 and abs(cv - av) / abs(cv) < 0.01:
126
- matching_cells += 0.8 # Partial credit
127
  except (ValueError, TypeError):
128
  pass
129
 
@@ -134,31 +137,122 @@ def compute_score(
134
  scores["value_accuracy"] = 0.0
135
 
136
  # ── Dimension 5: Data Preservation (10%) ──────────────────────────────
137
- # Penalize if valid rows from clean_df were incorrectly removed
138
  expected_rows = len(clean_df)
139
  current_rows = len(current_df)
140
 
141
  if current_rows >= expected_rows:
142
- # No valid data lost (might have extra rows from un-removed dupes)
143
  scores["data_preservation"] = 1.0
144
  elif current_rows == 0:
145
  scores["data_preservation"] = 0.0
146
  else:
147
- # Proportional penalty for missing rows
148
  scores["data_preservation"] = max(0.0, current_rows / expected_rows)
149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  # ── Composite Score ──────────────────────────────────────────────────
151
  weights = {
152
- "missing_fixed": 0.25,
153
- "duplicates_removed": 0.20,
154
- "type_correctness": 0.20,
155
- "value_accuracy": 0.25,
156
  "data_preservation": 0.10,
 
 
 
157
  }
158
 
159
  composite = sum(scores[dim] * weights[dim] for dim in weights)
160
- # Clamp to open interval (0, 1) β€” the OpenEnv validator rejects
161
- # scores of exactly 0.0 or 1.0.
162
  composite = max(0.001, min(0.999, composite))
163
 
164
  return composite, scores
@@ -173,30 +267,17 @@ def compute_step_reward(
173
  command: str,
174
  data_modified: bool,
175
  ) -> float:
176
- """Compute the reward for a single step.
177
-
178
- Args:
179
- before_df: DataFrame state before this step
180
- after_df: DataFrame state after this step
181
- clean_df: Ground truth
182
- original_dirty_df: Original dirty state
183
- issue_manifest: Issue manifest
184
- command: The command that was executed
185
- data_modified: Whether the command modified the data
186
-
187
- Returns:
188
- Step reward (can be negative)
189
- """
190
  cmd = command.strip().split()[0].lower() if command.strip() else ""
191
 
192
- # Diagnostic commands get small positive reward (encourage exploration)
193
  diagnostic_cmds = {"help", "view", "profile", "profile_column", "find_missing",
194
- "find_duplicates", "find_outliers"}
195
  if cmd in diagnostic_cmds:
196
  return 0.02
197
 
198
- # Validate gets small reward
199
- if cmd == "validate":
200
  return 0.01
201
 
202
  # Submit doesn't give step reward (final score is computed separately)
@@ -204,7 +285,6 @@ def compute_step_reward(
204
  return 0.001
205
 
206
  if not data_modified:
207
- # Command tried to modify but failed (or no change)
208
  return -0.01
209
 
210
  # For data-modifying commands, compare improvement
@@ -218,11 +298,8 @@ def compute_step_reward(
218
  improvement = after_score - before_score
219
 
220
  if improvement > 0:
221
- # Positive improvement β€” scale reward
222
  return min(0.15, improvement * 2.0 + 0.03)
223
  elif improvement < -0.01:
224
- # Made things worse β€” negative reward
225
  return max(-0.20, improvement * 2.0)
226
  else:
227
- # Negligible change
228
  return 0.001
 
2
  Grader for DataWranglerEnv.
3
 
4
  Multi-dimensional scoring system that compares the agent's cleaned dataset
5
+ against the ground truth. Produces deterministic scores in (0.001, 0.999).
6
 
7
  Dimensions:
8
+ - Missing values fixed (20%)
9
+ - Duplicates removed (15%)
10
+ - Type correctness (15%)
11
+ - Value accuracy (20%)
12
+ - Data preservation (10%)
13
+ - Constraint compliance (10%) β€” NEW: business rule satisfaction
14
+ - Step efficiency (5%) β€” NEW: fewer steps = better
15
+ - Golden row integrity (5%) β€” NEW: anti-exploit mechanism
16
  """
17
 
18
+ from typing import Any, Dict, List, Tuple
19
 
20
  import numpy as np
21
  import pandas as pd
 
27
  clean_df: pd.DataFrame,
28
  original_dirty_df: pd.DataFrame,
29
  issue_manifest: Dict[str, Any],
30
+ step_count: int = 0,
31
+ max_steps: int = 30,
32
+ golden_indices: List[int] = None,
33
  ) -> Tuple[float, Dict[str, float]]:
34
  """Compute the composite data quality score.
35
 
 
39
  clean_df: The ground truth clean dataset
40
  original_dirty_df: The very first dirty state (for reference)
41
  issue_manifest: Manifest of all injected issues
42
+ step_count: Current step number
43
+ max_steps: Maximum allowed steps
44
+ golden_indices: Indices of golden rows in clean_df
45
 
46
  Returns:
47
  Tuple of (composite_score, dimension_scores_dict)
48
  """
49
  scores = {}
50
+ if golden_indices is None:
51
+ golden_indices = issue_manifest.get("golden_indices", [])
52
 
53
+ # ── Dimension 1: Missing Values Fixed (20%) ──────────────────────────
54
  original_missing = original_dirty_df.isnull().sum().sum()
55
  current_missing = current_df.isnull().sum().sum()
56
  target_missing = clean_df.isnull().sum().sum()
 
62
  else:
63
  scores["missing_fixed"] = 1.0
64
 
65
+ # ── Dimension 2: Duplicates Removed (15%) ─────────────────────────────
66
  original_dupes = original_dirty_df.duplicated().sum()
67
  current_dupes = current_df.duplicated().sum()
68
  target_dupes = clean_df.duplicated().sum()
 
74
  else:
75
  scores["duplicates_removed"] = 1.0
76
 
77
+ # ── Dimension 3: Type Correctness (15%) ───────────────────────────────
78
  type_matches = 0
79
  type_total = len(clean_df.columns)
80
 
 
84
  clean_dtype = clean_df[col].dtype
85
  current_dtype = current_df[col].dtype
86
 
 
87
  if clean_dtype == current_dtype:
88
  type_matches += 1
89
  elif pd.api.types.is_numeric_dtype(clean_dtype) and pd.api.types.is_numeric_dtype(current_dtype):
 
95
 
96
  scores["type_correctness"] = type_matches / type_total if type_total > 0 else 1.0
97
 
98
+ # ── Dimension 4: Value Accuracy (20%) ─────────────────────────────────
 
 
99
  try:
 
100
  min_rows = min(len(current_df), len(clean_df))
 
 
101
  common_cols = [c for c in clean_df.columns if c in current_df.columns]
102
  if common_cols and min_rows > 0:
103
  clean_subset = clean_df[common_cols].head(min_rows).reset_index(drop=True)
 
115
  except (KeyError, IndexError):
116
  continue
117
 
 
118
  if pd.isna(clean_val) and pd.isna(current_val):
119
  matching_cells += 1
120
  elif pd.isna(clean_val) or pd.isna(current_val):
 
122
  elif str(clean_val).strip().lower() == str(current_val).strip().lower():
123
  matching_cells += 1
124
  else:
 
125
  try:
126
  cv = float(clean_val)
127
  av = float(current_val)
128
  if cv != 0 and abs(cv - av) / abs(cv) < 0.01:
129
+ matching_cells += 0.8
130
  except (ValueError, TypeError):
131
  pass
132
 
 
137
  scores["value_accuracy"] = 0.0
138
 
139
  # ── Dimension 5: Data Preservation (10%) ──────────────────────────────
 
140
  expected_rows = len(clean_df)
141
  current_rows = len(current_df)
142
 
143
  if current_rows >= expected_rows:
 
144
  scores["data_preservation"] = 1.0
145
  elif current_rows == 0:
146
  scores["data_preservation"] = 0.0
147
  else:
 
148
  scores["data_preservation"] = max(0.0, current_rows / expected_rows)
149
 
150
+ # ── Dimension 6: Constraint Compliance (10%) β€” NEW ────────────────────
151
+ business_rules = issue_manifest.get("business_rules", [])
152
+ if business_rules:
153
+ rules_satisfied = 0
154
+ rules_total = len(business_rules)
155
+ for rule in business_rules:
156
+ rule_type = rule.get("type", "")
157
+ col = rule.get("column", "")
158
+
159
+ if rule_type == "range" and col in current_df.columns:
160
+ lo, hi = rule.get("min", float("-inf")), rule.get("max", float("inf"))
161
+ numeric = pd.to_numeric(current_df[col], errors="coerce")
162
+ valid = numeric.dropna()
163
+ if len(valid) > 0:
164
+ pct_ok = ((valid >= lo) & (valid <= hi)).mean()
165
+ rules_satisfied += pct_ok
166
+
167
+ elif rule_type == "not_null" and col in current_df.columns:
168
+ pct_ok = 1.0 - (current_df[col].isna().sum() / max(1, len(current_df)))
169
+ rules_satisfied += pct_ok
170
+
171
+ elif rule_type == "cross_column":
172
+ col_a = rule.get("column_a", "")
173
+ col_b = rule.get("column_b", "")
174
+ if col_a in current_df.columns and col_b in current_df.columns:
175
+ a = pd.to_numeric(current_df[col_a], errors="coerce")
176
+ b = pd.to_numeric(current_df[col_b], errors="coerce")
177
+ valid_mask = a.notna() & b.notna()
178
+ if valid_mask.sum() > 0:
179
+ pct_ok = (a[valid_mask] > b[valid_mask]).mean()
180
+ rules_satisfied += pct_ok
181
+
182
+ elif rule_type == "categorical" and col in current_df.columns:
183
+ allowed = set(rule.get("allowed_values", []))
184
+ if allowed:
185
+ non_null = current_df[col].dropna()
186
+ if len(non_null) > 0:
187
+ pct_ok = non_null.isin(allowed).mean()
188
+ rules_satisfied += pct_ok
189
+
190
+ elif rule_type == "pattern" and col in current_df.columns:
191
+ pat = rule.get("pattern", "")
192
+ if pat:
193
+ non_null = current_df[col].dropna().astype(str)
194
+ if len(non_null) > 0:
195
+ pct_ok = non_null.str.match(pat).mean()
196
+ rules_satisfied += pct_ok
197
+
198
+ scores["constraint_compliance"] = rules_satisfied / rules_total if rules_total > 0 else 0.8
199
+ else:
200
+ scores["constraint_compliance"] = 0.8 # Default for tasks without rules
201
+
202
+ # ── Dimension 7: Step Efficiency (5%) β€” NEW ───────────────────────────
203
+ if step_count > 0 and max_steps > 0:
204
+ efficiency = 1.0 - (step_count / max_steps)
205
+ scores["step_efficiency"] = max(0.1, min(1.0, efficiency * 1.5))
206
+ else:
207
+ scores["step_efficiency"] = 0.5
208
+
209
+ # ── Dimension 8: Golden Row Integrity (5%) β€” NEW ──────────────────────
210
+ if golden_indices and len(current_df) > 0:
211
+ golden_ok = 0
212
+ golden_total = len(golden_indices)
213
+ for gi in golden_indices:
214
+ if gi >= len(clean_df):
215
+ continue
216
+ golden_row = clean_df.iloc[gi]
217
+ # Check if this golden row still exists in current_df (approximately)
218
+ found = False
219
+ for _, row in current_df.iterrows():
220
+ match_count = 0
221
+ total_check = 0
222
+ for col in clean_df.columns:
223
+ if col not in current_df.columns:
224
+ continue
225
+ total_check += 1
226
+ gv = golden_row[col]
227
+ cv = row[col]
228
+ if pd.isna(gv) and pd.isna(cv):
229
+ match_count += 1
230
+ elif not pd.isna(gv) and not pd.isna(cv):
231
+ if str(gv).strip().lower() == str(cv).strip().lower():
232
+ match_count += 1
233
+ if total_check > 0 and match_count / total_check > 0.8:
234
+ found = True
235
+ break
236
+ if found:
237
+ golden_ok += 1
238
+ scores["golden_row_integrity"] = golden_ok / golden_total if golden_total > 0 else 1.0
239
+ else:
240
+ scores["golden_row_integrity"] = 1.0
241
+
242
  # ── Composite Score ──────────────────────────────────────────────────
243
  weights = {
244
+ "missing_fixed": 0.20,
245
+ "duplicates_removed": 0.15,
246
+ "type_correctness": 0.15,
247
+ "value_accuracy": 0.20,
248
  "data_preservation": 0.10,
249
+ "constraint_compliance": 0.10,
250
+ "step_efficiency": 0.05,
251
+ "golden_row_integrity": 0.05,
252
  }
253
 
254
  composite = sum(scores[dim] * weights[dim] for dim in weights)
255
+ # Clamp to open interval (0, 1)
 
256
  composite = max(0.001, min(0.999, composite))
257
 
258
  return composite, scores
 
267
  command: str,
268
  data_modified: bool,
269
  ) -> float:
270
+ """Compute the reward for a single step."""
 
 
 
 
 
 
 
 
 
 
 
 
 
271
  cmd = command.strip().split()[0].lower() if command.strip() else ""
272
 
273
+ # Diagnostic commands get small positive reward
274
  diagnostic_cmds = {"help", "view", "profile", "profile_column", "find_missing",
275
+ "find_duplicates", "find_outliers", "check_rules", "history"}
276
  if cmd in diagnostic_cmds:
277
  return 0.02
278
 
279
+ # Validate and undo get small reward
280
+ if cmd in ("validate", "undo"):
281
  return 0.01
282
 
283
  # Submit doesn't give step reward (final score is computed separately)
 
285
  return 0.001
286
 
287
  if not data_modified:
 
288
  return -0.01
289
 
290
  # For data-modifying commands, compare improvement
 
298
  improvement = after_score - before_score
299
 
300
  if improvement > 0:
 
301
  return min(0.15, improvement * 2.0 + 0.03)
302
  elif improvement < -0.01:
 
303
  return max(-0.20, improvement * 2.0)
304
  else:
 
305
  return 0.001