mace / setup_demo.py
davidjurgens's picture
Deploy: Potato — MACE Agreement
a2c5eca verified
Raw
History Blame Contribute Delete
14 kB
#!/usr/bin/env python3
"""
Setup script for the MACE Competence Estimation Demo.
Generates synthetic annotation data from 5 annotators with different
quality profiles so MACE can estimate their competence.
Annotator profiles:
- reliable_1: Expert annotator, always correct
- reliable_2: Good annotator, mostly correct (8/10)
- moderate: Average annotator, correct ~60% of the time
- spammer: Low-quality annotator, nearly random
- biased: Always picks "positive" regardless of content
Ground truth labels (for reference):
review_01: positive review_06: positive
review_02: negative review_07: negative
review_03: positive review_08: neutral
review_04: neutral review_09: positive
review_05: negative review_10: negative
Usage:
python setup_demo.py # regenerate annotation data
python setup_demo.py --clean # also remove cached MACE results
"""
import argparse
import json
import os
import random
import shutil
import time
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
OUTPUT_DIR = os.path.join(SCRIPT_DIR, "annotation_output")
ITEM_IDS = [f"review_{i:02d}" for i in range(1, 11)]
LABELS = ["positive", "negative", "neutral"]
# Ground truth: what a correct annotator would label each item
GROUND_TRUTH = {
"review_01": "positive",
"review_02": "negative",
"review_03": "positive",
"review_04": "neutral",
"review_05": "negative",
"review_06": "positive",
"review_07": "negative",
"review_08": "neutral",
"review_09": "positive",
"review_10": "negative",
}
# ---------------------------------------------------------------
# Annotator definitions
# ---------------------------------------------------------------
# reliable_1: perfect agreement with ground truth
RELIABLE_1_LABELS = {
item_id: label for item_id, label in GROUND_TRUTH.items()
}
# reliable_2: mostly correct, wrong on review_04 and review_08
RELIABLE_2_LABELS = dict(GROUND_TRUTH)
RELIABLE_2_LABELS["review_04"] = "positive" # wrong (should be neutral)
RELIABLE_2_LABELS["review_08"] = "negative" # wrong (should be neutral)
# moderate: correct ~60%, wrong on review_02, 04, 07, 08
MODERATE_LABELS = dict(GROUND_TRUTH)
MODERATE_LABELS["review_02"] = "neutral" # wrong (should be negative)
MODERATE_LABELS["review_04"] = "positive" # wrong (should be neutral)
MODERATE_LABELS["review_07"] = "neutral" # wrong (should be negative)
MODERATE_LABELS["review_08"] = "positive" # wrong (should be neutral)
# spammer: nearly random, only 3/10 correct by chance
SPAMMER_LABELS = {
"review_01": "negative", # wrong
"review_02": "positive", # wrong
"review_03": "neutral", # wrong
"review_04": "neutral", # correct (by chance)
"review_05": "positive", # wrong
"review_06": "negative", # wrong
"review_07": "positive", # wrong
"review_08": "neutral", # correct (by chance)
"review_09": "negative", # wrong
"review_10": "negative", # correct (by chance)
}
# biased: always picks "positive"
BIASED_LABELS = {item_id: "positive" for item_id in ITEM_IDS}
# ---------------------------------------------------------------
# Behavioral data generation
# ---------------------------------------------------------------
# Generates full BehavioralData-compatible dicts with session timing,
# interactions, annotation changes, scroll depth, and navigation history.
def _make_behavioral(item_id, total_time_ms, final_label, base_time,
annotation_changes=None, scroll_depth=80.0,
deliberation_clicks=0):
"""Build a full BehavioralData-compatible dict for one instance.
Args:
item_id: Instance ID
total_time_ms: Total annotation time in milliseconds
final_label: The label that was ultimately selected
base_time: Unix timestamp for the start of this annotation session
annotation_changes: Optional list of annotation change dicts
scroll_depth: Maximum scroll depth (0-100)
deliberation_clicks: Number of extra interaction events to include
"""
session_start = base_time
session_end = base_time + total_time_ms / 1000.0
# Build interaction events: at minimum a "click" on the final label
interactions = []
# Reading / focus event
interactions.append({
"event_type": "focus_in",
"timestamp": session_start + 0.5,
"target": f"instance:{item_id}",
"instance_id": item_id,
"client_timestamp": (session_start + 0.5) * 1000,
"metadata": {},
})
# Deliberation clicks (e.g. scrolling, hovering over options)
for c in range(deliberation_clicks):
t = session_start + (total_time_ms / 1000.0) * (c + 1) / (deliberation_clicks + 2)
interactions.append({
"event_type": "click",
"timestamp": t,
"target": f"label:{random.choice(LABELS)}",
"instance_id": item_id,
"client_timestamp": t * 1000,
"metadata": {"deliberation": True},
})
# Final label selection
select_time = session_end - 1.0
interactions.append({
"event_type": "click",
"timestamp": select_time,
"target": f"label:{final_label}",
"instance_id": item_id,
"client_timestamp": select_time * 1000,
"metadata": {},
})
# Save event
interactions.append({
"event_type": "save",
"timestamp": session_end - 0.2,
"target": "nav:next",
"instance_id": item_id,
"client_timestamp": (session_end - 0.2) * 1000,
"metadata": {},
})
return {
"instance_id": item_id,
"session_start": session_start,
"session_end": session_end,
"total_time_ms": total_time_ms,
"interactions": interactions,
"ai_usage": [],
"annotation_changes": annotation_changes or [],
"navigation_history": [
{"action": "navigate_to", "instance_id": item_id, "timestamp": session_start},
{"action": "navigate_away", "instance_id": item_id, "timestamp": session_end},
],
"focus_time_by_element": {
f"instance:{item_id}": int(total_time_ms * 0.7),
f"schema:sentiment": int(total_time_ms * 0.3),
},
"scroll_depth_max": scroll_depth,
"keyword_highlights_shown": [],
}
def _build_behavioral_data(labels, time_profile, base_time, change_map=None):
"""Build behavioral data for all items for one annotator.
Args:
labels: Dict of item_id -> final label
time_profile: Dict of item_id -> (total_time_ms, scroll_depth, deliberation_clicks)
base_time: Starting unix timestamp (each item advances from previous)
change_map: Optional dict of item_id -> list of annotation change dicts
"""
behavioral = {}
current_time = base_time
for item_id in ITEM_IDS:
total_ms, scroll, delib = time_profile[item_id]
changes = (change_map or {}).get(item_id, [])
behavioral[item_id] = _make_behavioral(
item_id, total_ms, labels[item_id], current_time,
annotation_changes=changes, scroll_depth=scroll,
deliberation_clicks=delib,
)
# Next item starts after a short break
current_time += total_ms / 1000.0 + random.uniform(1.0, 3.0)
return behavioral
# Use a fixed seed for reproducibility
random.seed(42)
_BASE_TIME = 1738700000.0 # A fixed reference timestamp
# reliable_1: careful reader, consistent times 8-18s, high scroll depth
RELIABLE_1_TIME_PROFILE = {
"review_01": (15000, 95.0, 0), "review_02": (12000, 90.0, 0),
"review_03": (10000, 85.0, 0), "review_04": (18000, 98.0, 1),
"review_05": (8000, 80.0, 0), "review_06": (14000, 92.0, 0),
"review_07": (11000, 88.0, 0), "review_08": (16000, 95.0, 1),
"review_09": (9000, 82.0, 0), "review_10": (7000, 78.0, 0),
}
RELIABLE_1_BEHAVIORAL = _build_behavioral_data(
RELIABLE_1_LABELS, RELIABLE_1_TIME_PROFILE, _BASE_TIME)
# reliable_2: good annotator, deliberates on hard items (04, 08)
RELIABLE_2_TIME_PROFILE = {
"review_01": (12000, 88.0, 0), "review_02": (14000, 90.0, 0),
"review_03": (11000, 85.0, 0),
"review_04": (20000, 98.0, 3), # deliberated, changed mind
"review_05": (9000, 80.0, 0), "review_06": (13000, 87.0, 0),
"review_07": (10000, 84.0, 0),
"review_08": (22000, 98.0, 3), # deliberated, changed mind
"review_09": (8000, 78.0, 0), "review_10": (10000, 82.0, 0),
}
RELIABLE_2_CHANGES = {
"review_04": [
{"timestamp": 5000, "schema_name": "sentiment", "action": "select",
"new_value": "neutral", "source": "user"},
{"timestamp": 12000, "schema_name": "sentiment", "action": "select",
"new_value": "positive", "source": "user"},
],
"review_08": [
{"timestamp": 4000, "schema_name": "sentiment", "action": "select",
"new_value": "neutral", "source": "user"},
{"timestamp": 14000, "schema_name": "sentiment", "action": "select",
"new_value": "negative", "source": "user"},
],
}
RELIABLE_2_BEHAVIORAL = _build_behavioral_data(
RELIABLE_2_LABELS, RELIABLE_2_TIME_PROFILE, _BASE_TIME + 300,
change_map=RELIABLE_2_CHANGES)
# moderate: average speed, moderate scroll, some deliberation
MODERATE_TIME_PROFILE = {
item_id: (8000 + i * 1000, 60.0 + i * 3, 1 if i % 3 == 0 else 0)
for i, item_id in enumerate(ITEM_IDS)
}
MODERATE_BEHAVIORAL = _build_behavioral_data(
MODERATE_LABELS, MODERATE_TIME_PROFILE, _BASE_TIME + 600)
# spammer: very fast times (barely reading), low scroll depth, no deliberation
SPAMMER_TIME_PROFILE = {
item_id: (500 + i * 200, 10.0 + i * 2, 0)
for i, item_id in enumerate(ITEM_IDS)
}
SPAMMER_BEHAVIORAL = _build_behavioral_data(
SPAMMER_LABELS, SPAMMER_TIME_PROFILE, _BASE_TIME + 900)
# biased: moderate times but never changes mind, moderate scroll
BIASED_TIME_PROFILE = {
item_id: (3000 + i * 500, 45.0 + i * 4, 0)
for i, item_id in enumerate(ITEM_IDS)
}
BIASED_BEHAVIORAL = _build_behavioral_data(
BIASED_LABELS, BIASED_TIME_PROFILE, _BASE_TIME + 1200)
# User state template (matches Potato's expected format)
USER_STATE_TEMPLATE = {
"current_phase_and_page": ["annotation", "annotation"],
"completed_phase_and_pages": [],
"max_assignments": -1,
"instance_id_to_span_to_value": {},
"phase_to_page_to_label_to_value": {},
"phase_to_page_to_span_to_value": {},
"training_state": {
"completed_questions": {},
"total_correct": 0,
"total_attempts": 0,
"total_mistakes": 0,
"passed": False,
"failed": False,
"current_question_index": 0,
"training_instances": [],
"show_feedback": False,
"feedback_message": "",
"allow_retry": False,
"max_mistakes": -1,
"max_mistakes_per_question": -1,
"category_scores": {},
},
"instance_id_to_keyword_highlight_state": {},
}
def build_user_state(user_id, labels, behavioral):
"""Build a complete user_state.json dict from label and behavioral data."""
state = dict(USER_STATE_TEMPLATE)
state["user_id"] = user_id
state["instance_id_ordering"] = list(ITEM_IDS)
state["current_instance_index"] = len(ITEM_IDS) - 1
# Convert labels to the list-of-pairs format used by Potato
# Format: {item_id: [[{"schema": "sentiment", "name": label}, True]]}
label_map = {}
for item_id, label in labels.items():
label_map[item_id] = [
[{"schema": "sentiment", "name": label}, True]
]
state["instance_id_to_label_to_value"] = label_map
state["instance_id_to_behavioral_data"] = behavioral
return state
def write_user_state(user_id, state):
"""Write a user_state.json file for the given user."""
user_dir = os.path.join(OUTPUT_DIR, user_id)
os.makedirs(user_dir, exist_ok=True)
filepath = os.path.join(user_dir, "user_state.json")
with open(filepath, "w") as f:
json.dump(state, f, indent=2)
print(f" Wrote {filepath}")
def clean_mace_cache():
"""Remove cached MACE results."""
mace_dir = os.path.join(OUTPUT_DIR, "mace")
if os.path.exists(mace_dir):
shutil.rmtree(mace_dir)
print(f" Removed {mace_dir}")
def main():
parser = argparse.ArgumentParser(description="Reset MACE demo data")
parser.add_argument(
"--clean",
action="store_true",
help="Also remove cached MACE results",
)
args = parser.parse_args()
print("Generating synthetic annotation data for MACE demo...")
print()
annotators = [
("reliable_1", RELIABLE_1_LABELS, RELIABLE_1_BEHAVIORAL),
("reliable_2", RELIABLE_2_LABELS, RELIABLE_2_BEHAVIORAL),
("moderate", MODERATE_LABELS, MODERATE_BEHAVIORAL),
("spammer", SPAMMER_LABELS, SPAMMER_BEHAVIORAL),
("biased", BIASED_LABELS, BIASED_BEHAVIORAL),
]
for user_id, labels, behavioral in annotators:
state = build_user_state(user_id, labels, behavioral)
write_user_state(user_id, state)
if args.clean:
print("\nCleaning MACE cache...")
clean_mace_cache()
print()
print("Done! Start the server with:")
print(" python potato/flask_server.py start "
"examples/advanced/mace-demo/config.yaml -p 8000")
print()
print("Then trigger MACE and view results:")
print(' curl -X POST http://localhost:8000/admin/api/mace/trigger '
'-H "X-API-Key: demo-mace-key"')
print(' curl http://localhost:8000/admin/api/mace/overview '
'-H "X-API-Key: demo-mace-key" | python -m json.tool')
print(' curl "http://localhost:8000/admin/api/mace/predictions?schema=sentiment" '
'-H "X-API-Key: demo-mace-key" | python -m json.tool')
print()
print("Expected competence ranking (high to low):")
print(" reliable_1 > reliable_2 > moderate > biased ~ spammer")
if __name__ == "__main__":
main()