Threadbourne commited on
Commit
4d887cd
·
verified ·
1 Parent(s): f5b7025

Update app.py

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