Threadbourne commited on
Commit
4555061
·
verified ·
1 Parent(s): 94946be

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +386 -0
app.py ADDED
@@ -0,0 +1,386 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,
361
+ inputs=[file, turn_col, mag_col, rolling_window, band_width, stability_thresh, persistence],
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")
374
+
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,
382
+ inputs=[file, perturb_type, strength, seed, turn_col_for_scramble, noise_col],
383
+ outputs=[pert_plot, pert_msg, pert_preview],
384
+ )
385
+
386
+ demo.launch()