Threadbourne commited on
Commit
d454cb7
·
verified ·
1 Parent(s): f38c4a4

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +96 -310
app.py CHANGED
@@ -1,360 +1,134 @@
1
  import gradio as gr
2
- import pandas as pd
3
  import numpy as np
 
4
  import matplotlib.pyplot as plt
5
 
6
- # ----------------------------
7
- # Helpers
8
- # ----------------------------
 
9
 
10
- def safe_read_csv(file):
11
  df = pd.read_csv(file.name)
12
  if df.empty:
13
- raise ValueError("CSV loaded but contains no rows.")
14
- return df
15
-
16
- def infer_defaults(df: pd.DataFrame):
17
- cols = list(df.columns)
18
-
19
- # turn candidate
20
- turn_default = "turn" if "turn" in cols else None
21
-
22
- # speaker candidate
23
- speaker_default = "speaker" if "speaker" in cols else None
24
-
25
- # magnitude candidate
26
- numeric_cols = list(df.select_dtypes(include=[np.number]).columns)
27
- mag_default = None
28
- for c in ["tokens_est", "tokens", "words", "chars", "length"]:
29
- if c in numeric_cols:
30
- mag_default = c
31
- break
32
- if mag_default is None and numeric_cols:
33
- mag_default = numeric_cols[0]
34
-
35
- return turn_default, speaker_default, mag_default, cols, numeric_cols
36
-
37
- def compute_stability(df: pd.DataFrame, turn_col: str, mag_col: str,
38
- rolling_window: int, band_width: float,
39
- stability_thresh: float, persistence: int):
40
- """
41
- Returns:
42
- df_out with rolling stats + stable flag
43
- stable_segments list of (start_turn, end_turn, length)
44
- """
45
- d = df.copy()
46
-
47
- if mag_col not in d.columns:
48
- raise ValueError(f"Selected magnitude column '{mag_col}' not found.")
49
- if not pd.api.types.is_numeric_dtype(d[mag_col]):
50
- raise ValueError(f"Selected magnitude column '{mag_col}' is not numeric.")
51
 
52
- # Sort by turn if possible, else keep row order as index
53
- if turn_col and turn_col in d.columns and pd.api.types.is_numeric_dtype(d[turn_col]):
54
- d = d.sort_values(turn_col).reset_index(drop=True)
 
55
  x = d[turn_col].to_numpy()
56
  else:
57
- x = np.arange(len(d))
58
- turn_col = None # treat as index
 
 
 
 
59
 
60
  y = d[mag_col].astype(float).to_numpy()
61
 
62
  w = int(rolling_window)
63
- w = max(3, min(w, len(d))) # enforce sensible range
64
-
65
- s = pd.Series(y)
66
- roll_mean = s.rolling(w, min_periods=max(3, w//3)).mean().to_numpy()
67
- roll_std = s.rolling(w, min_periods=max(3, w//3)).std(ddof=0).to_numpy()
68
-
69
- # Avoid division issues
70
- eps = 1e-9
71
- z = (y - roll_mean) / (roll_std + eps)
72
-
73
- # Stability criterion:
74
- # "within stability_thresh sigmas of rolling mean"
75
- stable = np.abs(z) <= float(stability_thresh)
76
-
77
- # Persistence: stable for N consecutive turns
78
- p = int(persistence)
79
- p = max(1, p)
80
-
81
- stable_persist = np.zeros_like(stable, dtype=bool)
82
- run = 0
83
- for i, ok in enumerate(stable):
84
- if ok and not np.isnan(roll_mean[i]) and not np.isnan(roll_std[i]):
85
- run += 1
86
- else:
87
- run = 0
88
- if run >= p:
89
- stable_persist[i] = True
90
-
91
- # Bands (for plotting)
92
- bw = float(band_width)
93
- upper = roll_mean + bw * roll_std
94
- lower = roll_mean - bw * roll_std
95
-
96
- d["_x"] = x
97
- d["_y"] = y
98
- d["_roll_mean"] = roll_mean
99
- d["_roll_std"] = roll_std
100
- d["_band_upper"] = upper
101
- d["_band_lower"] = lower
102
- d["_z"] = z
103
- d["_stable"] = stable_persist
104
-
105
- # Extract stable segments (using stable_persist)
106
- segments = []
107
- in_seg = False
108
- seg_start_idx = None
109
-
110
- for i, ok in enumerate(stable_persist):
111
- if ok and not in_seg:
112
- in_seg = True
113
- seg_start_idx = i
114
- if in_seg and (not ok or i == len(stable_persist) - 1):
115
- seg_end_idx = i if ok else i - 1
116
- in_seg = False
117
-
118
- start_turn = d.loc[seg_start_idx, "_x"]
119
- end_turn = d.loc[seg_end_idx, "_x"]
120
- length = seg_end_idx - seg_start_idx + 1
121
- segments.append((start_turn, end_turn, length))
122
 
123
- return d, segments
124
-
125
- def plot_drift_hold(d: pd.DataFrame, title: str, show_points: bool = True):
126
  fig = plt.figure(figsize=(8, 4.5))
127
  ax = fig.add_subplot(111)
128
-
129
- ax.plot(d["_x"], d["_roll_mean"], label="Rolling mean")
130
- ax.plot(d["_x"], d["_band_upper"], label="Band upper")
131
- ax.plot(d["_x"], d["_band_lower"], label="Band lower")
132
-
133
- if show_points:
134
- ax.scatter(d["_x"], d["_y"], s=8, alpha=0.6, label="Turns")
135
-
136
- # highlight stable points
137
- stable_idx = d["_stable"].fillna(False).to_numpy(dtype=bool)
138
- if stable_idx.any():
139
- ax.scatter(d.loc[stable_idx, "_x"], d.loc[stable_idx, "_y"], s=14, alpha=0.9, label="Stable (persist)")
140
-
141
- ax.set_title(title)
142
- ax.set_xlabel("Turn" if "_x" in d.columns else "Index")
143
  ax.set_ylabel("Magnitude")
144
  ax.legend()
145
  fig.tight_layout()
146
- return fig
147
-
148
- # ----------------------------
149
- # Perturbations
150
- # ----------------------------
151
-
152
- def temporal_scramble(df: pd.DataFrame, strength: float, seed: int, turn_col: str):
153
- rng = np.random.default_rng(int(seed))
154
- d = df.copy()
155
-
156
- if turn_col and turn_col in d.columns and pd.api.types.is_numeric_dtype(d[turn_col]):
157
- d = d.sort_values(turn_col).reset_index(drop=True)
158
- else:
159
- d = d.reset_index(drop=True)
160
-
161
- n = len(d)
162
- if n < 2 or strength <= 0:
163
- return d
164
 
165
- window = int(1 + float(strength) * (n - 1))
166
- window = max(1, min(window, n))
167
 
168
- idx = np.arange(n)
169
- out = idx.copy()
170
- for start in range(0, n, window):
171
- end = min(start + window, n)
172
- chunk = out[start:end].copy()
173
- rng.shuffle(chunk)
174
- out[start:end] = chunk
175
-
176
- return d.iloc[out].reset_index(drop=True)
177
-
178
- def metric_noise(df: pd.DataFrame, strength: float, seed: int, col: str):
179
- rng = np.random.default_rng(int(seed))
180
- d = df.copy()
181
-
182
- if col not in d.columns:
183
- raise ValueError(f"Noise column '{col}' not found.")
184
- if not pd.api.types.is_numeric_dtype(d[col]):
185
- raise ValueError(f"Noise column '{col}' is not numeric.")
186
-
187
- x = d[col].astype(float).to_numpy()
188
- if len(x) < 2 or strength <= 0:
189
- return d
190
-
191
- std = float(np.std(x))
192
- if std == 0:
193
- return d
194
-
195
- noise = rng.normal(0, float(strength) * std, size=len(x))
196
- d[col] = x + noise
197
- return d
198
 
199
  # ----------------------------
200
- # UI callbacks
201
  # ----------------------------
202
-
203
- def on_upload(file):
204
- if file is None:
205
- return (gr.update(choices=[], value=None),
206
- gr.update(choices=[], value=None),
207
- gr.update(choices=[], value=None),
208
- gr.update(choices=[], value=None),
209
- gr.update(choices=[], value=None),
210
- "Upload a CSV to begin.",
211
- None)
212
-
213
- df = safe_read_csv(file)
214
- turn_default, speaker_default, mag_default, cols, numeric_cols = infer_defaults(df)
215
-
216
- status = f"Loaded {len(df)} rows, {len(df.columns)} columns."
217
- preview = df.head(15)
218
-
219
- return (gr.update(choices=cols, value=turn_default),
220
- gr.update(choices=cols, value=speaker_default),
221
- gr.update(choices=numeric_cols, value=mag_default),
222
- gr.update(choices=numeric_cols, value=mag_default),
223
- gr.update(choices=["None"] + cols, value=turn_default if turn_default else "None"),
224
- status,
225
- preview)
226
-
227
- def run_drift_hold(file, turn_col, mag_col, rolling_window, band_width, stability_thresh, persistence):
228
- if file is None:
229
- return None, "Upload a CSV first.", None
230
-
231
- df = safe_read_csv(file)
232
-
233
- # normalize 'None'
234
- turn_col = None if (turn_col in [None, "None"] or turn_col not in df.columns) else turn_col
235
-
236
- d, segments = compute_stability(
237
- df=df,
238
- turn_col=turn_col,
239
- mag_col=mag_col,
240
- rolling_window=int(rolling_window),
241
- band_width=float(band_width),
242
- stability_thresh=float(stability_thresh),
243
- persistence=int(persistence),
244
- )
245
-
246
- fig = plot_drift_hold(d, title=f"Drift & Hold — {mag_col}")
247
-
248
- if segments:
249
- seg_lines = "\n".join([f"• Stable segment: {s:.0f} → {e:.0f} (len={L})" for s, e, L in segments[:10]])
250
- msg = f"Detected {len(segments)} stable segment(s).\n{seg_lines}"
251
- else:
252
- msg = "No stable segments detected with current settings."
253
-
254
- return fig, msg, d[["turn"]].head(0) if "turn" not in d.columns else d[["turn", "_y", "_roll_mean", "_roll_std", "_stable"]].head(15)
255
-
256
- def run_perturb(file, perturb_type, strength, seed, turn_col_for_scramble, noise_col):
257
- if file is None:
258
- return None, "Upload a CSV first.", None
259
-
260
- df = safe_read_csv(file)
261
-
262
- # resolve turn column
263
- if turn_col_for_scramble in [None, "None"] or turn_col_for_scramble not in df.columns:
264
- turn_col = None
265
- else:
266
- turn_col = turn_col_for_scramble
267
-
268
- if perturb_type == "Temporal scramble":
269
- df2 = temporal_scramble(df, strength=float(strength), seed=int(seed), turn_col=turn_col)
270
- msg = f"Applied temporal scramble (strength={strength})."
271
- else:
272
- df2 = metric_noise(df, strength=float(strength), seed=int(seed), col=noise_col)
273
- msg = f"Applied metric noise to '{noise_col}' (strength={strength})."
274
-
275
- # quick default plot column
276
- _, _, mag_default, _, numeric_cols = infer_defaults(df2)
277
- if not mag_default:
278
- return None, "No numeric columns available to plot.", df2.head(15)
279
-
280
- # x-axis
281
- if turn_col and pd.api.types.is_numeric_dtype(df2[turn_col]):
282
- x = df2[turn_col].to_numpy()
283
- else:
284
- x = np.arange(len(df2))
285
-
286
- fig = plt.figure(figsize=(8, 4.5))
287
- ax = fig.add_subplot(111)
288
- ax.plot(x, df2[mag_default].astype(float).to_numpy())
289
- ax.set_title(f"Perturbed — {mag_default}")
290
- ax.set_xlabel(turn_col if turn_col else "Index")
291
- ax.set_ylabel(mag_default)
292
- fig.tight_layout()
293
-
294
- return fig, msg, df2.head(15)
295
-
296
- # ----------------------------
297
- # App
298
- # ----------------------------
299
-
300
  with gr.Blocks(title="Threadscope: Drift & Hold") as demo:
301
  gr.Markdown(
302
- "Threadscope: Drift & Hold — Bring Your Own Thread\n\n"
303
- "Upload a CSV to visualize long-form interaction dynamics. "
304
- "Processed in-session only (no storage)."
305
  )
306
 
307
- with gr.Row():
308
- file = gr.File(label="Upload CSV", file_types=[".csv"])
309
 
310
  status = gr.Textbox(label="Status", interactive=False)
311
 
312
- with gr.Row():
313
- turn_col = gr.Dropdown(label="Turn column (optional)", choices=[], value=None)
314
- speaker_col = gr.Dropdown(label="Speaker column (optional)", choices=[], value=None)
315
- mag_col = gr.Dropdown(label="Magnitude column (numeric)", choices=[], value=None)
 
 
316
 
317
- with gr.Row():
318
  preview = gr.Dataframe(label="Preview (first 15 rows)", interactive=False, wrap=True)
319
 
320
- file.change(
321
- fn=on_upload,
322
- inputs=[file],
323
- outputs=[turn_col, speaker_col, mag_col, # main mapping
324
- # reuse: noise column + scramble turn dropdown will be set by same list later
325
- # We'll set them in the UI below using same choices/values:
326
- # placeholders:
327
- ],
328
- )
329
-
330
- # Workaround: Gradio requires explicit outputs; we'll update extra dropdowns via a second handler
331
- noise_col = gr.Dropdown(label="Noise column (numeric)", choices=[], value=None)
332
- turn_col_for_scramble = gr.Dropdown(label="Turn column for scramble (optional)", choices=[], value="None")
333
-
334
- # Update all dropdowns + status + preview on upload
335
- def on_upload_all(file):
336
- tc, sc, mc, nc, tcs, st, pv = on_upload(file)
337
- return tc, sc, mc, nc, tcs, st, pv
338
 
 
339
  file.change(
340
  fn=on_upload_all,
341
  inputs=[file],
342
  outputs=[turn_col, speaker_col, mag_col, noise_col, turn_col_for_scramble, status, preview],
343
  )
344
 
 
345
  with gr.Tabs():
346
- with gr.Tab("Drift & Hold"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
  with gr.Row():
348
  rolling_window = gr.Slider(3, 200, value=25, step=1, label="Rolling window (turns)")
349
- band_width = gr.Slider(0.5, 4.0, value=2.0, step=0.1, label="Band width (σ multiplier)")
 
350
  with gr.Row():
351
- stability_thresh = gr.Slider(0.5, 4.0, value=1.0, step=0.1, label="Stability threshold (|z| σ)")
352
- persistence = gr.Slider(1, 100, value=10, step=1, label="Required persistence (turns)")
353
 
354
  run_btn = gr.Button("Run Drift & Hold")
355
  out_plot = gr.Plot(label="Drift & Hold plot")
356
- out_msg = gr.Textbox(label="Notes", interactive=False)
357
- out_table = gr.Dataframe(label="Computed preview", interactive=False, wrap=True)
 
 
358
 
359
  run_btn.click(
360
  fn=run_drift_hold,
@@ -362,12 +136,22 @@ with gr.Blocks(title="Threadscope: Drift & Hold") as demo:
362
  outputs=[out_plot, out_msg, out_table],
363
  )
364
 
365
- with gr.Tab("Perturbations"):
 
 
 
 
 
 
 
 
 
366
  perturb_type = gr.Dropdown(
367
  label="Perturbation type",
368
  choices=["Temporal scramble", "Metric noise injection"],
369
  value="Temporal scramble",
370
  )
 
371
  with gr.Row():
372
  strength = gr.Slider(0, 1, value=0.35, step=0.01, label="Strength")
373
  seed = gr.Number(value=7, precision=0, label="Seed")
@@ -375,7 +159,9 @@ with gr.Blocks(title="Threadscope: Drift & Hold") as demo:
375
  run_perturb_btn = gr.Button("Apply perturbation")
376
  pert_plot = gr.Plot(label="Perturbed plot")
377
  pert_msg = gr.Textbox(label="Notes", interactive=False)
378
- pert_preview = gr.Dataframe(label="Perturbed preview (first 15 rows)", interactive=False, wrap=True)
 
 
379
 
380
  run_perturb_btn.click(
381
  fn=run_perturb,
 
1
  import gradio as gr
 
2
  import numpy as np
3
+ import pandas as pd
4
  import matplotlib.pyplot as plt
5
 
6
+ # --- QUICK VIEW CALLBACK (new, lightweight) ---
7
+ def run_quick_view(file, turn_col, mag_col, rolling_window):
8
+ if file is None:
9
+ return None, "Upload a CSV first."
10
 
 
11
  df = pd.read_csv(file.name)
12
  if df.empty:
13
+ return None, "CSV is empty."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
+ # Resolve turn axis
16
+ use_turn = (turn_col not in [None, "None"] and turn_col in df.columns and pd.api.types.is_numeric_dtype(df[turn_col]))
17
+ if use_turn:
18
+ d = df.sort_values(turn_col).reset_index(drop=True)
19
  x = d[turn_col].to_numpy()
20
  else:
21
+ d = df.reset_index(drop=True)
22
+ x = np.arange(len(d)) + 1 # friendlier 1-based index
23
+
24
+ # Validate magnitude
25
+ if mag_col not in d.columns or not pd.api.types.is_numeric_dtype(d[mag_col]):
26
+ return None, f"'{mag_col}' is not a numeric magnitude column."
27
 
28
  y = d[mag_col].astype(float).to_numpy()
29
 
30
  w = int(rolling_window)
31
+ w = max(3, min(w, len(d)))
32
+ roll = pd.Series(y).rolling(w, min_periods=max(3, w // 3)).mean().to_numpy()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
 
 
 
34
  fig = plt.figure(figsize=(8, 4.5))
35
  ax = fig.add_subplot(111)
36
+ ax.scatter(x, y, s=8, alpha=0.6, label="Turns")
37
+ ax.plot(x, roll, label=f"Rolling mean (w={w})")
38
+ ax.set_title(f"Quick View {mag_col}")
39
+ ax.set_xlabel(turn_col if use_turn else "Turn (row order)")
 
 
 
 
 
 
 
 
 
 
 
40
  ax.set_ylabel("Magnitude")
41
  ax.legend()
42
  fig.tight_layout()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
+ msg = "Quick View: response magnitude over time. Use Advanced tabs for stability bands + perturbations."
45
+ return fig, msg
46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
 
48
  # ----------------------------
49
+ # APP UI
50
  # ----------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  with gr.Blocks(title="Threadscope: Drift & Hold") as demo:
52
  gr.Markdown(
53
+ "## Threadscope: Drift & Hold — Bring Your Own Thread\n"
54
+ "Upload a CSV to visualize long-form interaction dynamics. **Processed in-session only (no storage).**\n\n"
55
+ "**Quick start:** upload Quick View → adjust rolling window."
56
  )
57
 
58
+ # Upload first
59
+ file = gr.File(label="Upload", file_types=[".csv"])
60
 
61
  status = gr.Textbox(label="Status", interactive=False)
62
 
63
+ # Keep mapping, but tuck into a collapsible section
64
+ with gr.Accordion("Data mapping (expand if needed)", open=False):
65
+ with gr.Row():
66
+ turn_col = gr.Dropdown(label="Turn column (optional)", choices=[], value=None)
67
+ speaker_col = gr.Dropdown(label="Speaker column (optional)", choices=[], value=None)
68
+ mag_col = gr.Dropdown(label="Magnitude column (numeric)", choices=[], value=None)
69
 
 
70
  preview = gr.Dataframe(label="Preview (first 15 rows)", interactive=False, wrap=True)
71
 
72
+ # Advanced-only controls that still need file-derived choices
73
+ with gr.Accordion("Advanced inputs (perturbations)", open=False):
74
+ noise_col = gr.Dropdown(label="Noise column (numeric)", choices=[], value=None)
75
+ turn_col_for_scramble = gr.Dropdown(label="Turn column for scramble (optional)", choices=[], value="None")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
 
77
+ # Update all dropdowns + preview on upload
78
  file.change(
79
  fn=on_upload_all,
80
  inputs=[file],
81
  outputs=[turn_col, speaker_col, mag_col, noise_col, turn_col_for_scramble, status, preview],
82
  )
83
 
84
+ # Tabs: Quick first, Advanced next
85
  with gr.Tabs():
86
+ # ----------------------------
87
+ # QUICK VIEW (default)
88
+ # ----------------------------
89
+ with gr.Tab("Quick View"):
90
+ gr.Markdown(
91
+ "**What this shows:** response magnitude over time + rolling mean.\n\n"
92
+ "If you want stability detection (bands + persistence), open **Drift & Hold (Advanced)**."
93
+ )
94
+ rolling_window_q = gr.Slider(3, 200, value=25, step=1, label="Rolling window (turns)")
95
+ run_quick = gr.Button("Run Quick View")
96
+ quick_plot = gr.Plot(label="Quick plot")
97
+ quick_msg = gr.Textbox(label="Notes", interactive=False)
98
+
99
+ run_quick.click(
100
+ fn=run_quick_view,
101
+ inputs=[file, turn_col, mag_col, rolling_window_q],
102
+ outputs=[quick_plot, quick_msg],
103
+ )
104
+
105
+ # ----------------------------
106
+ # DRIFT & HOLD (ADVANCED)
107
+ # ----------------------------
108
+ with gr.Tab("Drift & Hold (Advanced)"):
109
+ gr.Markdown(
110
+ "**Stability settings (start with defaults):**\n"
111
+ "- Rolling window = 25\n"
112
+ "- How wide is “normal”? = 2.0\n"
113
+ "- How strict is “stable”? = 1.0\n"
114
+ "- How long must it stay stable? = 10\n\n"
115
+ "Adjust one slider at a time."
116
+ )
117
+
118
  with gr.Row():
119
  rolling_window = gr.Slider(3, 200, value=25, step=1, label="Rolling window (turns)")
120
+ band_width = gr.Slider(0.5, 4.0, value=2.0, step=0.1, label="How wide is “normal”? (band width)")
121
+
122
  with gr.Row():
123
+ stability_thresh = gr.Slider(0.5, 4.0, value=1.0, step=0.1, label="How strict is “stable”? (threshold)")
124
+ persistence = gr.Slider(1, 100, value=10, step=1, label="How long must it stay stable? (persistence)")
125
 
126
  run_btn = gr.Button("Run Drift & Hold")
127
  out_plot = gr.Plot(label="Drift & Hold plot")
128
+ out_msg = gr.Textbox(label="Summary", interactive=False)
129
+
130
+ with gr.Accordion("Details (computed preview)", open=False):
131
+ out_table = gr.Dataframe(label="Computed preview", interactive=False, wrap=True)
132
 
133
  run_btn.click(
134
  fn=run_drift_hold,
 
136
  outputs=[out_plot, out_msg, out_table],
137
  )
138
 
139
+ # ----------------------------
140
+ # PERTURBATIONS (ADVANCED)
141
+ # ----------------------------
142
+ with gr.Tab("Perturbations (Advanced)"):
143
+ gr.Markdown(
144
+ "**Use this to test robustness:**\n"
145
+ "- **Temporal scramble** breaks order but keeps values.\n"
146
+ "- **Metric noise** perturbs values but keeps order."
147
+ )
148
+
149
  perturb_type = gr.Dropdown(
150
  label="Perturbation type",
151
  choices=["Temporal scramble", "Metric noise injection"],
152
  value="Temporal scramble",
153
  )
154
+
155
  with gr.Row():
156
  strength = gr.Slider(0, 1, value=0.35, step=0.01, label="Strength")
157
  seed = gr.Number(value=7, precision=0, label="Seed")
 
159
  run_perturb_btn = gr.Button("Apply perturbation")
160
  pert_plot = gr.Plot(label="Perturbed plot")
161
  pert_msg = gr.Textbox(label="Notes", interactive=False)
162
+
163
+ with gr.Accordion("Perturbed preview (first 15 rows)", open=False):
164
+ pert_preview = gr.Dataframe(interactive=False, wrap=True)
165
 
166
  run_perturb_btn.click(
167
  fn=run_perturb,