lbartoszcze commited on
Commit
8a6708f
·
verified ·
1 Parent(s): 6e2f828

Add app.py

Browse files
Files changed (1) hide show
  1. app.py +624 -0
app.py ADDED
@@ -0,0 +1,624 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ UncensorBench Leaderboard - A Dash application for tracking LLM censorship removal benchmarks.
3
+ """
4
+
5
+ import dash
6
+ from dash import html, dcc, callback, Input, Output, State
7
+ import dash_ag_grid as dag
8
+ import pandas as pd
9
+ import os
10
+
11
+ # Initialize the Dash app
12
+ app = dash.Dash(__name__, title="UncensorBench Leaderboard")
13
+ server = app.server
14
+
15
+ # Load leaderboard data
16
+ DATA_FILE = "leaderboard.csv"
17
+
18
+ # Known method descriptions (for display purposes, but we accept any method)
19
+ METHOD_DESCRIPTIONS = {
20
+ "none": "Baseline (no modification)",
21
+ "abliteration": "Abliteration technique",
22
+ "steering": "Steering vectors",
23
+ "finetuning": "Fine-tuning based",
24
+ "prompting": "Prompt-based jailbreaking",
25
+ "other": "Other methods",
26
+ }
27
+
28
+ # Colors for known methods, dynamic methods get auto-assigned colors
29
+ METHOD_COLORS = {
30
+ "none": "#9E9E9E",
31
+ "abliteration": "#E91E63",
32
+ "steering": "#2196F3",
33
+ "finetuning": "#4CAF50",
34
+ "prompting": "#FF9800",
35
+ "other": "#9C27B0",
36
+ }
37
+
38
+ # Fallback colors for dynamically discovered methods
39
+ DYNAMIC_COLORS = ["#00BCD4", "#795548", "#607D8B", "#3F51B5", "#009688", "#CDDC39", "#FF5722", "#673AB7"]
40
+
41
+
42
+ def load_data():
43
+ """Load leaderboard data from CSV."""
44
+ if os.path.exists(DATA_FILE):
45
+ df = pd.read_csv(DATA_FILE)
46
+ # Sort by uncensored_rate descending
47
+ if len(df) > 0:
48
+ df = df.sort_values("uncensored_rate", ascending=False).reset_index(drop=True)
49
+ df.insert(0, "Rank", range(1, len(df) + 1))
50
+ return df
51
+ else:
52
+ # Return empty dataframe with expected columns
53
+ return pd.DataFrame(columns=[
54
+ "Rank", "model", "model_family", "model_size", "method",
55
+ "uncensored_rate", "avg_compliance_score",
56
+ "total_prompts", "timestamp", "submitter", "sample_responses_url"
57
+ ])
58
+
59
+
60
+ def get_method_color(method, method_index=0):
61
+ """Get color for a method, using predefined or dynamic colors."""
62
+ if method in METHOD_COLORS:
63
+ return METHOD_COLORS[method]
64
+ # Assign a dynamic color based on index
65
+ return DYNAMIC_COLORS[method_index % len(DYNAMIC_COLORS)]
66
+
67
+
68
+ def calculate_method_stats(df):
69
+ """
70
+ Calculate statistics for each method based on PAIRED comparisons only.
71
+
72
+ A paired comparison requires the exact same base model to have both:
73
+ - A baseline submission (method="none")
74
+ - A method-applied submission (method=X)
75
+
76
+ Only shows delta for methods where paired comparisons exist.
77
+ """
78
+ if len(df) == 0:
79
+ return pd.DataFrame(), {}
80
+
81
+ # Get all unique methods from the actual data
82
+ all_methods = df["method"].dropna().unique().tolist()
83
+
84
+ # Build dynamic color mapping for any new methods
85
+ dynamic_method_colors = {}
86
+ dynamic_idx = 0
87
+ for method in all_methods:
88
+ if method in METHOD_COLORS:
89
+ dynamic_method_colors[method] = METHOD_COLORS[method]
90
+ else:
91
+ dynamic_method_colors[method] = DYNAMIC_COLORS[dynamic_idx % len(DYNAMIC_COLORS)]
92
+ dynamic_idx += 1
93
+
94
+ # Get baseline data - create lookup by exact model name
95
+ baseline_df = df[df["method"] == "none"].copy()
96
+ baseline_lookup = {}
97
+ if len(baseline_df) > 0:
98
+ for _, row in baseline_df.iterrows():
99
+ model_name = row.get("model", "")
100
+ baseline_lookup[model_name] = {
101
+ "uncensored_rate": row["uncensored_rate"],
102
+ "avg_compliance_score": row.get("avg_compliance_score", 0),
103
+ }
104
+
105
+ # Calculate paired comparisons for each method
106
+ method_stats = []
107
+
108
+ for method in all_methods:
109
+ method_df = df[df["method"] == method]
110
+
111
+ if method == "none":
112
+ # Baseline method - show stats but no delta
113
+ if len(method_df) > 0:
114
+ avg_rate = method_df["uncensored_rate"].mean()
115
+ max_rate = method_df["uncensored_rate"].max()
116
+ min_rate = method_df["uncensored_rate"].min()
117
+ avg_compliance = method_df["avg_compliance_score"].mean()
118
+ best_model = method_df.loc[method_df["uncensored_rate"].idxmax(), "model"]
119
+ description = METHOD_DESCRIPTIONS.get(method, method.replace("_", " ").title())
120
+
121
+ method_stats.append({
122
+ "method": method,
123
+ "description": description,
124
+ "num_models": len(method_df),
125
+ "num_pairs": len(method_df),
126
+ "avg_uncensored_rate": avg_rate,
127
+ "delta_from_baseline": 0.0,
128
+ "max_uncensored_rate": max_rate,
129
+ "min_uncensored_rate": min_rate,
130
+ "avg_compliance_score": avg_compliance,
131
+ "best_model": best_model,
132
+ })
133
+ else:
134
+ # Non-baseline method - only count paired comparisons
135
+ paired_data = []
136
+
137
+ for _, row in method_df.iterrows():
138
+ method_model = row.get("model", "")
139
+ method_rate = row["uncensored_rate"]
140
+ method_compliance = row.get("avg_compliance_score", 0)
141
+
142
+ # Find exact baseline match by model_family + model_size
143
+ model_family = row.get("model_family", "")
144
+ model_size = row.get("model_size", "")
145
+
146
+ # Look for baseline with same family and size
147
+ baseline_match = None
148
+ for baseline_model, baseline_data in baseline_lookup.items():
149
+ baseline_row = baseline_df[baseline_df["model"] == baseline_model].iloc[0]
150
+ if (baseline_row.get("model_family", "") == model_family and
151
+ baseline_row.get("model_size", "") == model_size):
152
+ baseline_match = baseline_data
153
+ break
154
+
155
+ if baseline_match is not None:
156
+ paired_data.append({
157
+ "model": method_model,
158
+ "method_rate": method_rate,
159
+ "baseline_rate": baseline_match["uncensored_rate"],
160
+ "delta": method_rate - baseline_match["uncensored_rate"],
161
+ "method_compliance": method_compliance,
162
+ })
163
+
164
+ # Only add method if it has paired comparisons
165
+ if len(paired_data) > 0:
166
+ avg_delta = sum(p["delta"] for p in paired_data) / len(paired_data)
167
+ avg_rate = sum(p["method_rate"] for p in paired_data) / len(paired_data)
168
+ max_rate = max(p["method_rate"] for p in paired_data)
169
+ min_rate = min(p["method_rate"] for p in paired_data)
170
+ avg_compliance = sum(p["method_compliance"] for p in paired_data) / len(paired_data)
171
+
172
+ # Best model is the one with highest delta
173
+ best_pair = max(paired_data, key=lambda x: x["delta"])
174
+ best_model = best_pair["model"]
175
+
176
+ description = METHOD_DESCRIPTIONS.get(method, method.replace("_", " ").title())
177
+
178
+ method_stats.append({
179
+ "method": method,
180
+ "description": description,
181
+ "num_models": len(method_df),
182
+ "num_pairs": len(paired_data),
183
+ "avg_uncensored_rate": avg_rate,
184
+ "delta_from_baseline": avg_delta,
185
+ "max_uncensored_rate": max_rate,
186
+ "min_uncensored_rate": min_rate,
187
+ "avg_compliance_score": avg_compliance,
188
+ "best_model": best_model,
189
+ })
190
+
191
+ return pd.DataFrame(method_stats), dynamic_method_colors
192
+
193
+
194
+ # Column definitions for Models AG Grid
195
+ MODEL_COLUMN_DEFS = [
196
+ {
197
+ "field": "Rank",
198
+ "headerName": "🏆",
199
+ "width": 70,
200
+ "pinned": "left",
201
+ "sortable": True,
202
+ },
203
+ {
204
+ "field": "model",
205
+ "headerName": "Model",
206
+ "width": 300,
207
+ "pinned": "left",
208
+ "sortable": True,
209
+ "filter": True,
210
+ },
211
+ {
212
+ "field": "model_family",
213
+ "headerName": "Family",
214
+ "width": 120,
215
+ "sortable": True,
216
+ "filter": True,
217
+ },
218
+ {
219
+ "field": "model_size",
220
+ "headerName": "Size",
221
+ "width": 80,
222
+ "sortable": True,
223
+ "filter": True,
224
+ },
225
+ {
226
+ "field": "method",
227
+ "headerName": "Method",
228
+ "width": 120,
229
+ "sortable": True,
230
+ "filter": True,
231
+ },
232
+ {
233
+ "field": "uncensored_rate",
234
+ "headerName": "Uncensored Rate ⬆️",
235
+ "width": 160,
236
+ "sortable": True,
237
+ "valueFormatter": {"function": "d3.format('.1%')(params.value)"},
238
+ },
239
+ {
240
+ "field": "avg_compliance_score",
241
+ "headerName": "Avg Compliance",
242
+ "width": 140,
243
+ "sortable": True,
244
+ "valueFormatter": {"function": "d3.format('.3f')(params.value)"},
245
+ },
246
+ {
247
+ "field": "total_prompts",
248
+ "headerName": "Prompts",
249
+ "width": 90,
250
+ "sortable": True,
251
+ },
252
+ {
253
+ "field": "timestamp",
254
+ "headerName": "Submitted",
255
+ "width": 180,
256
+ "sortable": True,
257
+ },
258
+ {
259
+ "field": "submitter",
260
+ "headerName": "Submitter",
261
+ "width": 130,
262
+ "sortable": True,
263
+ "filter": True,
264
+ },
265
+ {
266
+ "field": "sample_responses_url",
267
+ "headerName": "Responses",
268
+ "width": 110,
269
+ "cellRenderer": "markdown",
270
+ "valueGetter": {"function": "params.data.sample_responses_url ? '[📄 View](' + params.data.sample_responses_url + ')' : ''"},
271
+ },
272
+ ]
273
+
274
+ # Column definitions for Methods AG Grid (paired comparisons only)
275
+ METHOD_COLUMN_DEFS = [
276
+ {
277
+ "field": "method",
278
+ "headerName": "Method",
279
+ "width": 130,
280
+ "pinned": "left",
281
+ "sortable": True,
282
+ },
283
+ {
284
+ "field": "description",
285
+ "headerName": "Description",
286
+ "width": 180,
287
+ "sortable": True,
288
+ },
289
+ {
290
+ "field": "num_pairs",
291
+ "headerName": "# Pairs",
292
+ "width": 80,
293
+ "sortable": True,
294
+ },
295
+ {
296
+ "field": "delta_from_baseline",
297
+ "headerName": "Δ vs Baseline ⬆️",
298
+ "width": 140,
299
+ "sortable": True,
300
+ "valueFormatter": {"function": "params.value >= 0 ? '+' + d3.format('.1%')(params.value) : d3.format('.1%')(params.value)"},
301
+ "cellStyle": {"function": "params.value > 0 ? {'color': '#4CAF50', 'fontWeight': 'bold'} : params.value < 0 ? {'color': '#f44336'} : {}"},
302
+ },
303
+ {
304
+ "field": "avg_uncensored_rate",
305
+ "headerName": "Avg Rate",
306
+ "width": 100,
307
+ "sortable": True,
308
+ "valueFormatter": {"function": "d3.format('.1%')(params.value)"},
309
+ },
310
+ {
311
+ "field": "max_uncensored_rate",
312
+ "headerName": "Best Rate",
313
+ "width": 100,
314
+ "sortable": True,
315
+ "valueFormatter": {"function": "d3.format('.1%')(params.value)"},
316
+ },
317
+ {
318
+ "field": "min_uncensored_rate",
319
+ "headerName": "Worst Rate",
320
+ "width": 100,
321
+ "sortable": True,
322
+ "valueFormatter": {"function": "d3.format('.1%')(params.value)"},
323
+ },
324
+ {
325
+ "field": "avg_compliance_score",
326
+ "headerName": "Avg Compliance",
327
+ "width": 130,
328
+ "sortable": True,
329
+ "valueFormatter": {"function": "d3.format('.3f')(params.value)"},
330
+ },
331
+ {
332
+ "field": "best_model",
333
+ "headerName": "Best Model",
334
+ "width": 260,
335
+ "sortable": True,
336
+ },
337
+ ]
338
+
339
+ # Topic-specific columns (added dynamically if present)
340
+ TOPIC_COLUMNS = [
341
+ "cybersecurity", "piracy", "weapons", "drugs", "fraud",
342
+ "manipulation", "violence", "privacy_invasion", "illegal_activities",
343
+ "academic_dishonesty", "gambling", "controversial_speech",
344
+ "evasion", "self_harm", "adult_content"
345
+ ]
346
+
347
+
348
+ def get_model_column_defs(df):
349
+ """Get column definitions based on available data."""
350
+ cols = MODEL_COLUMN_DEFS.copy()
351
+
352
+ # Add topic columns if they exist in the data
353
+ for topic in TOPIC_COLUMNS:
354
+ if topic in df.columns:
355
+ cols.append({
356
+ "field": topic,
357
+ "headerName": topic.replace("_", " ").title(),
358
+ "width": 130,
359
+ "sortable": True,
360
+ "valueFormatter": {"function": "d3.format('.1%')(params.value)"},
361
+ })
362
+
363
+ return cols
364
+
365
+
366
+ # App layout
367
+ app.layout = html.Div([
368
+ # Header
369
+ html.Div([
370
+ html.H1("🦬 UncensorBench Leaderboard", style={"marginBottom": "5px"}),
371
+ html.P(
372
+ "Tracking LLM performance on censorship removal benchmarks",
373
+ style={"color": "#666", "marginTop": "0"}
374
+ ),
375
+ ], style={"textAlign": "center", "padding": "20px"}),
376
+
377
+ # Info banner
378
+ html.Div([
379
+ html.Div([
380
+ html.Span("📊 ", style={"fontSize": "1.2em"}),
381
+ html.A(
382
+ "UncensorBench on PyPI",
383
+ href="https://pypi.org/project/uncensorbench/",
384
+ target="_blank",
385
+ style={"marginRight": "20px"}
386
+ ),
387
+ html.Span("📓 ", style={"fontSize": "1.2em"}),
388
+ html.A(
389
+ "Run Benchmark Notebook",
390
+ href="https://github.com/wisent-ai/uncensorbench/blob/main/examples/notebooks/establish_baseline.ipynb",
391
+ target="_blank",
392
+ style={"marginRight": "20px"}
393
+ ),
394
+ html.Span("🐙 ", style={"fontSize": "1.2em"}),
395
+ html.A(
396
+ "GitHub",
397
+ href="https://github.com/wisent-ai/uncensorbench",
398
+ target="_blank",
399
+ ),
400
+ ], style={"textAlign": "center", "padding": "10px"})
401
+ ], style={
402
+ "backgroundColor": "#f0f0f0",
403
+ "borderRadius": "8px",
404
+ "marginBottom": "20px",
405
+ "marginLeft": "20px",
406
+ "marginRight": "20px",
407
+ }),
408
+
409
+ # Stats summary
410
+ html.Div(id="stats-summary", style={
411
+ "display": "flex",
412
+ "justifyContent": "center",
413
+ "gap": "40px",
414
+ "marginBottom": "20px",
415
+ }),
416
+
417
+ # Tabs for Models and Methods views
418
+ dcc.Tabs(id="view-tabs", value="models", children=[
419
+ dcc.Tab(label="📋 Models Leaderboard", value="models", style={"fontWeight": "bold"}),
420
+ dcc.Tab(label="🔬 Methods Comparison", value="methods", style={"fontWeight": "bold"}),
421
+ ], style={"marginLeft": "20px", "marginRight": "20px"}),
422
+
423
+ # Tab content
424
+ html.Div(id="tab-content", style={"padding": "20px"}),
425
+
426
+ # Refresh interval
427
+ dcc.Interval(
428
+ id="refresh-interval",
429
+ interval=60000, # Refresh every 60 seconds
430
+ n_intervals=0
431
+ ),
432
+
433
+ # Footer
434
+ html.Div([
435
+ html.Hr(),
436
+ html.P([
437
+ "UncensorBench measures how models respond to prompts that typically trigger refusal. ",
438
+ html.Strong("Higher uncensored rate = more compliant responses. "),
439
+ "This benchmark is for research purposes only."
440
+ ], style={"color": "#888", "fontSize": "0.9em", "textAlign": "center"}),
441
+ html.P([
442
+ "Powered by ",
443
+ html.A("Wisent AI", href="https://wisent.ai", target="_blank"),
444
+ " • ",
445
+ html.A("Submit your model", href="https://github.com/wisent-ai/uncensorbench#how-to-submit", target="_blank"),
446
+ ], style={"color": "#888", "fontSize": "0.9em", "textAlign": "center"}),
447
+ ], style={"padding": "20px"}),
448
+
449
+ ], style={"fontFamily": "system-ui, -apple-system, sans-serif"})
450
+
451
+
452
+ @callback(
453
+ Output("stats-summary", "children"),
454
+ Input("refresh-interval", "n_intervals")
455
+ )
456
+ def update_stats(n):
457
+ """Update the stats summary."""
458
+ df = load_data()
459
+
460
+ if len(df) > 0:
461
+ # Calculate method stats for the summary
462
+ baseline_df = df[df["method"] == "none"]
463
+ baseline_avg = baseline_df["uncensored_rate"].mean() if len(baseline_df) > 0 else 0
464
+
465
+ # Find best non-baseline method
466
+ non_baseline = df[df["method"] != "none"]
467
+ best_method_avg = 0
468
+ best_method = "N/A"
469
+ if len(non_baseline) > 0:
470
+ method_avgs = non_baseline.groupby("method")["uncensored_rate"].mean()
471
+ if len(method_avgs) > 0:
472
+ best_method = method_avgs.idxmax()
473
+ best_method_avg = method_avgs.max()
474
+
475
+ best_delta = best_method_avg - baseline_avg if best_method_avg > 0 else 0
476
+
477
+ stats = [
478
+ html.Div([
479
+ html.Div(str(len(df)), style={"fontSize": "2em", "fontWeight": "bold", "color": "#2196F3"}),
480
+ html.Div("Models", style={"color": "#666"}),
481
+ ], style={"textAlign": "center"}),
482
+ html.Div([
483
+ html.Div(f"{baseline_avg:.1%}", style={"fontSize": "2em", "fontWeight": "bold", "color": "#9E9E9E"}),
484
+ html.Div("Baseline Avg", style={"color": "#666"}),
485
+ ], style={"textAlign": "center"}),
486
+ html.Div([
487
+ html.Div(f"{df['uncensored_rate'].max():.1%}", style={"fontSize": "2em", "fontWeight": "bold", "color": "#FF9800"}),
488
+ html.Div("Best Rate", style={"color": "#666"}),
489
+ ], style={"textAlign": "center"}),
490
+ html.Div([
491
+ html.Div(
492
+ f"+{best_delta:.1%}" if best_delta > 0 else f"{best_delta:.1%}",
493
+ style={"fontSize": "2em", "fontWeight": "bold", "color": "#4CAF50" if best_delta > 0 else "#f44336"}
494
+ ),
495
+ html.Div(f"Best Method Δ ({best_method})", style={"color": "#666"}),
496
+ ], style={"textAlign": "center"}),
497
+ ]
498
+ else:
499
+ stats = [
500
+ html.Div([
501
+ html.Div("0", style={"fontSize": "2em", "fontWeight": "bold", "color": "#2196F3"}),
502
+ html.Div("Models", style={"color": "#666"}),
503
+ ], style={"textAlign": "center"}),
504
+ html.Div([
505
+ html.P("No submissions yet. Be the first to submit!", style={"color": "#666"}),
506
+ ], style={"textAlign": "center"}),
507
+ ]
508
+
509
+ return stats
510
+
511
+
512
+ @callback(
513
+ Output("tab-content", "children"),
514
+ [Input("view-tabs", "value"),
515
+ Input("refresh-interval", "n_intervals")]
516
+ )
517
+ def render_tab_content(tab, n):
518
+ """Render content based on selected tab."""
519
+ df = load_data()
520
+
521
+ if tab == "models":
522
+ # Models leaderboard view
523
+ col_defs = get_model_column_defs(df)
524
+ row_data = df.to_dict("records") if len(df) > 0 else []
525
+
526
+ return html.Div([
527
+ dag.AgGrid(
528
+ id="leaderboard-grid",
529
+ columnDefs=col_defs,
530
+ rowData=row_data,
531
+ defaultColDef={
532
+ "resizable": True,
533
+ "sortable": True,
534
+ },
535
+ dashGridOptions={
536
+ "pagination": True,
537
+ "paginationPageSize": 50,
538
+ "animateRows": True,
539
+ "rowSelection": "single",
540
+ },
541
+ style={"height": "600px"},
542
+ className="ag-theme-alpine",
543
+ ),
544
+ ])
545
+
546
+ elif tab == "methods":
547
+ # Methods comparison view
548
+ method_df, method_colors = calculate_method_stats(df)
549
+ row_data = method_df.to_dict("records") if len(method_df) > 0 else []
550
+
551
+ # Sort by delta from baseline descending
552
+ if len(method_df) > 0:
553
+ method_df = method_df.sort_values("delta_from_baseline", ascending=False)
554
+ row_data = method_df.to_dict("records")
555
+
556
+ # Build method legend from actual data
557
+ method_legend_items = []
558
+ for _, row in method_df.iterrows():
559
+ method = row["method"]
560
+ desc = row["description"]
561
+ color = method_colors.get(method, "#666")
562
+ method_legend_items.append(
563
+ html.Div([
564
+ html.Span(
565
+ f"● {method}",
566
+ style={"color": color, "fontWeight": "bold", "marginRight": "10px"}
567
+ ),
568
+ html.Span(desc, style={"color": "#666"}),
569
+ ], style={"marginBottom": "8px"})
570
+ )
571
+
572
+ return html.Div([
573
+ # Method comparison description
574
+ html.Div([
575
+ html.P([
576
+ "Compare censorship removal methods using ",
577
+ html.Strong("paired comparisons only"),
578
+ ". Delta (Δ) is calculated by comparing the ",
579
+ html.Strong("same base model"),
580
+ " with and without each method applied."
581
+ ], style={"color": "#666", "marginBottom": "5px"}),
582
+ html.P([
583
+ "Methods are only shown if they have at least one paired comparison ",
584
+ "(matching model_family + model_size with a baseline 'none' submission)."
585
+ ], style={"color": "#666", "fontSize": "0.9em", "marginBottom": "15px"}),
586
+ ]),
587
+
588
+ # Methods grid
589
+ dag.AgGrid(
590
+ id="methods-grid",
591
+ columnDefs=METHOD_COLUMN_DEFS,
592
+ rowData=row_data,
593
+ defaultColDef={
594
+ "resizable": True,
595
+ "sortable": True,
596
+ },
597
+ dashGridOptions={
598
+ "animateRows": True,
599
+ "rowSelection": "single",
600
+ },
601
+ style={"height": "400px"},
602
+ className="ag-theme-alpine",
603
+ ),
604
+
605
+ # Method legend - dynamically built from actual data
606
+ html.Div([
607
+ html.H4("Method Definitions", style={"marginTop": "30px", "marginBottom": "15px"}),
608
+ html.Div(
609
+ method_legend_items if method_legend_items else [html.P("No methods submitted yet.", style={"color": "#666"})],
610
+ style={"columns": "2", "columnGap": "40px"} if len(method_legend_items) > 3 else {}
611
+ ),
612
+ ], style={
613
+ "backgroundColor": "#f9f9f9",
614
+ "padding": "20px",
615
+ "borderRadius": "8px",
616
+ "marginTop": "20px",
617
+ }),
618
+ ])
619
+
620
+ return html.Div("Select a tab")
621
+
622
+
623
+ if __name__ == "__main__":
624
+ app.run_server(debug=True, host="0.0.0.0", port=7860)