Merlintxu commited on
Commit
98dff6d
·
verified ·
1 Parent(s): 4d72032

Update conversation_storyline/plots.py

Browse files
Files changed (1) hide show
  1. conversation_storyline/plots.py +277 -83
conversation_storyline/plots.py CHANGED
@@ -1,83 +1,277 @@
1
- from __future__ import annotations
2
-
3
- from typing import Dict, List, Tuple
4
- import numpy as np
5
- import pandas as pd
6
- import plotly.graph_objects as go
7
- import networkx as nx
8
-
9
-
10
- def plot_reply_distance_hist(metrics: pd.DataFrame) -> go.Figure:
11
- d = metrics["reply_distance"].dropna().astype(int)
12
- fig = go.Figure()
13
- fig.add_histogram(x=d, nbinsx=40)
14
- fig.update_layout(title="Distribución distancia reply_to (message_id - reply_to_id)", xaxis_title="distancia", yaxis_title="conteo")
15
- return fig
16
-
17
-
18
- def plot_interaction_heatmap(inter_matrix: pd.DataFrame) -> go.Figure:
19
- fig = go.Figure(data=go.Heatmap(z=inter_matrix.values, x=inter_matrix.columns, y=inter_matrix.index))
20
- fig.update_layout(title="Heatmap interacciones (conteo respuestas)", xaxis_title="to", yaxis_title="from")
21
- return fig
22
-
23
-
24
- def plot_bump_activity(metrics: pd.DataFrame) -> go.Figure:
25
- # actividad por topic_id y speaker
26
- if "topic_id" not in metrics.columns:
27
- fig = go.Figure()
28
- fig.update_layout(title="Bump actividad (no topic_id)")
29
- return fig
30
-
31
- piv = metrics.pivot_table(index="topic_id", columns="speaker", values="message_id", aggfunc="count", fill_value=0)
32
- # rank por segmento (mayor actividad = rank 1)
33
- ranks = piv.rank(axis=1, method="average", ascending=False)
34
-
35
- fig = go.Figure()
36
- for sp in piv.columns:
37
- fig.add_trace(go.Scatter(x=piv.index, y=ranks[sp], mode="lines+markers", name=sp))
38
- fig.update_layout(
39
- title="Bump chart: ranking actividad por segmento",
40
- xaxis_title="topic_id",
41
- yaxis_title="rank (1 = más activo)",
42
- yaxis_autorange="reversed",
43
- )
44
- return fig
45
-
46
-
47
- def plot_sankey_speaker_to_topic(metrics: pd.DataFrame) -> go.Figure:
48
- if "topic_label" not in metrics.columns:
49
- fig = go.Figure()
50
- fig.update_layout(title="Sankey (no topic_label)")
51
- return fig
52
-
53
- speakers = metrics["speaker"].unique().tolist()
54
- topics = metrics["topic_label"].fillna("Tema").unique().tolist()
55
-
56
- s_idx = {s: i for i, s in enumerate(speakers)}
57
- t_idx = {t: i + len(speakers) for i, t in enumerate(topics)}
58
-
59
- links = metrics.groupby(["speaker", "topic_label"])["message_id"].count().reset_index()
60
- source = [s_idx[r["speaker"]] for _, r in links.iterrows()]
61
- target = [t_idx[r["topic_label"]] for _, r in links.iterrows()]
62
- value = links["message_id"].tolist()
63
-
64
- labels = speakers + topics
65
-
66
- fig = go.Figure(
67
- data=[
68
- go.Sankey(
69
- node=dict(label=labels, pad=10, thickness=12),
70
- link=dict(source=source, target=target, value=value),
71
- )
72
- ]
73
- )
74
- fig.update_layout(title="Sankey: Speaker → Topic (volumen de mensajes)")
75
- return fig
76
-
77
-
78
- def plot_sentiment_placeholder(metrics: pd.DataFrame) -> go.Figure:
79
- fig = go.Figure()
80
- if "sentiment" in metrics.columns and metrics["sentiment"].notna().any():
81
- fig.add_trace(go.Scatter(x=metrics["message_id"], y=metrics["sentiment"], mode="lines+markers"))
82
- fig.update_layout(title="Sentiment (si disponible)", xaxis_title="message_id", yaxis_title="sentiment")
83
- return fig
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Dict, List, Tuple
4
+ import numpy as np
5
+ import pandas as pd
6
+ import plotly.graph_objects as go
7
+ import networkx as nx
8
+
9
+
10
+ def plot_reply_distance_hist(metrics: pd.DataFrame) -> go.Figure:
11
+ d = metrics["reply_distance"].dropna().astype(int)
12
+ fig = go.Figure()
13
+ fig.add_histogram(x=d, nbinsx=40)
14
+ fig.update_layout(title="Distribución distancia reply_to (message_id - reply_to_id)", xaxis_title="distancia", yaxis_title="conteo")
15
+ return fig
16
+
17
+
18
+ def plot_interaction_heatmap(inter_matrix: pd.DataFrame) -> go.Figure:
19
+ fig = go.Figure(data=go.Heatmap(z=inter_matrix.values, x=inter_matrix.columns, y=inter_matrix.index))
20
+ fig.update_layout(title="Heatmap interacciones (conteo respuestas)", xaxis_title="to", yaxis_title="from")
21
+ return fig
22
+
23
+
24
+ def plot_bump_activity(metrics: pd.DataFrame) -> go.Figure:
25
+ # actividad por topic_id y speaker
26
+ if "topic_id" not in metrics.columns:
27
+ fig = go.Figure()
28
+ fig.update_layout(title="Bump actividad (no topic_id)")
29
+ return fig
30
+
31
+ piv = metrics.pivot_table(index="topic_id", columns="speaker", values="message_id", aggfunc="count", fill_value=0)
32
+ # rank por segmento (mayor actividad = rank 1)
33
+ ranks = piv.rank(axis=1, method="average", ascending=False)
34
+
35
+ fig = go.Figure()
36
+ for sp in piv.columns:
37
+ fig.add_trace(go.Scatter(x=piv.index, y=ranks[sp], mode="lines+markers", name=sp))
38
+ fig.update_layout(
39
+ title="Bump chart: ranking actividad por segmento",
40
+ xaxis_title="topic_id",
41
+ yaxis_title="rank (1 = más activo)",
42
+ yaxis_autorange="reversed",
43
+ )
44
+ return fig
45
+
46
+
47
+ def plot_sankey_speaker_to_topic(metrics: pd.DataFrame) -> go.Figure:
48
+ if "topic_label" not in metrics.columns:
49
+ fig = go.Figure()
50
+ fig.update_layout(title="Sankey (no topic_label)")
51
+ return fig
52
+
53
+ speakers = metrics["speaker"].unique().tolist()
54
+ topics = metrics["topic_label"].fillna("Tema").unique().tolist()
55
+
56
+ s_idx = {s: i for i, s in enumerate(speakers)}
57
+ t_idx = {t: i + len(speakers) for i, t in enumerate(topics)}
58
+
59
+ links = metrics.groupby(["speaker", "topic_label"])["message_id"].count().reset_index()
60
+ source = [s_idx[r["speaker"]] for _, r in links.iterrows()]
61
+ target = [t_idx[r["topic_label"]] for _, r in links.iterrows()]
62
+ value = links["message_id"].tolist()
63
+
64
+ labels = speakers + topics
65
+
66
+ fig = go.Figure(
67
+ data=[
68
+ go.Sankey(
69
+ node=dict(label=labels, pad=10, thickness=12),
70
+ link=dict(source=source, target=target, value=value),
71
+ )
72
+ ]
73
+ )
74
+ fig.update_layout(title="Sankey: Speaker → Topic (volumen de mensajes)")
75
+ return fig
76
+
77
+
78
+ def plot_sentiment_placeholder(metrics: pd.DataFrame) -> go.Figure:
79
+ fig = go.Figure()
80
+ if "sentiment" in metrics.columns and metrics["sentiment"].notna().any():
81
+ fig.add_trace(go.Scatter(x=metrics["message_id"], y=metrics["sentiment"], mode="lines+markers"))
82
+ fig.update_layout(title="Sentiment (si disponible)", xaxis_title="message_id", yaxis_title="sentiment")
83
+ return fig
84
+ # conversation_storyline/plots.py
85
+ """
86
+ Plot helpers for Conversation Storyline outputs.
87
+
88
+ Derived from:
89
+ - interactions.jsonl
90
+ - graph.json
91
+
92
+ Returns Plotly figures (Gradio-friendly).
93
+ """
94
+
95
+ from __future__ import annotations
96
+
97
+ import json
98
+ from pathlib import Path
99
+ from typing import Dict, List, Tuple
100
+
101
+ import pandas as pd
102
+ import plotly.express as px
103
+ import plotly.graph_objects as go
104
+
105
+ INTERACTIONS_FILENAME = "interactions.jsonl"
106
+ GRAPH_FILENAME = "graph.json"
107
+
108
+
109
+ def _read_jsonl(path: Path) -> List[dict]:
110
+ rows: List[dict] = []
111
+ with path.open("r", encoding="utf-8") as f:
112
+ for line in f:
113
+ line = line.strip()
114
+ if not line:
115
+ continue
116
+ rows.append(json.loads(line))
117
+ return rows
118
+
119
+
120
+ def load_interactions_df(out_dir: str | Path) -> pd.DataFrame:
121
+ out_dir = Path(out_dir)
122
+ path = out_dir / INTERACTIONS_FILENAME
123
+ if not path.exists():
124
+ raise FileNotFoundError(f"Missing {INTERACTIONS_FILENAME} in {out_dir}")
125
+ df = pd.DataFrame(_read_jsonl(path))
126
+ if "id" in df.columns:
127
+ df = df.sort_values("id").reset_index(drop=True)
128
+ if "speaker" in df.columns:
129
+ df["speaker"] = df["speaker"].astype(str).fillna("?")
130
+ if "topic_label" in df.columns:
131
+ df["topic_label"] = df["topic_label"].astype(str).fillna("?")
132
+ if "sentiment_score" in df.columns:
133
+ df["sentiment_score"] = pd.to_numeric(df["sentiment_score"], errors="coerce").fillna(0.0)
134
+ if "reply_to_id" in df.columns:
135
+ df["reply_to_id"] = pd.to_numeric(df["reply_to_id"], errors="coerce")
136
+ return df
137
+
138
+
139
+ def load_graph_json(out_dir: str | Path) -> dict:
140
+ out_dir = Path(out_dir)
141
+ path = out_dir / GRAPH_FILENAME
142
+ if not path.exists():
143
+ raise FileNotFoundError(f"Missing {GRAPH_FILENAME} in {out_dir}")
144
+ return json.loads(path.read_text(encoding="utf-8"))
145
+
146
+
147
+ def plot_sentiment_over_time(df: pd.DataFrame, rolling_window: int | None = None) -> go.Figure:
148
+ if df.empty:
149
+ return go.Figure()
150
+
151
+ n = len(df)
152
+ if rolling_window is None:
153
+ rolling_window = max(3, min(25, int(n * 0.05)))
154
+
155
+ dfx = df[["id", "speaker", "sentiment_score"]].copy()
156
+ dfx["rolling_sentiment"] = (
157
+ dfx.groupby("speaker")["sentiment_score"]
158
+ .transform(lambda s: s.rolling(rolling_window, min_periods=1).mean())
159
+ )
160
+
161
+ fig = px.line(dfx, x="id", y="rolling_sentiment", color="speaker")
162
+ fig.update_layout(
163
+ title=f"Sentiment (media móvil {rolling_window} mensajes)",
164
+ xaxis_title="Turno (id)",
165
+ yaxis_title="Sentiment",
166
+ height=420,
167
+ )
168
+ return fig
169
+
170
+
171
+ def plot_sentiment_histogram(df: pd.DataFrame) -> go.Figure:
172
+ if df.empty:
173
+ return go.Figure()
174
+ fig = px.histogram(df, x="sentiment_score", nbins=30)
175
+ fig.update_layout(
176
+ title="Distribución de sentiment",
177
+ xaxis_title="Sentiment",
178
+ yaxis_title="Nº de mensajes",
179
+ height=380,
180
+ )
181
+ return fig
182
+
183
+
184
+ def plot_speaker_topic_heatmap(df: pd.DataFrame, max_topics: int = 20) -> go.Figure:
185
+ if df.empty:
186
+ return go.Figure()
187
+
188
+ top_topics = df["topic_label"].value_counts().head(max_topics).index.tolist()
189
+ sub = df[df["topic_label"].isin(top_topics)]
190
+ pivot = pd.pivot_table(sub, index="speaker", columns="topic_label", values="id", aggfunc="count", fill_value=0)
191
+
192
+ fig = px.imshow(pivot, aspect="auto", color_continuous_scale="Blues")
193
+ fig.update_layout(
194
+ title="Heatmap: speaker × topic (conteo de mensajes)",
195
+ xaxis_title="Topic",
196
+ yaxis_title="Speaker",
197
+ height=520,
198
+ )
199
+ return fig
200
+
201
+
202
+ def plot_speaker_activity_heatmap(df: pd.DataFrame, bins: int = 25) -> go.Figure:
203
+ """
204
+ Heatmap: speakers vs time buckets. Useful for large chats.
205
+ """
206
+ if df.empty:
207
+ return go.Figure()
208
+
209
+ n = len(df)
210
+ bins = max(10, min(bins, 60))
211
+ df = df.copy()
212
+ df["bucket"] = pd.cut(df["id"], bins=bins, labels=False, include_lowest=True)
213
+ pivot = pd.pivot_table(df, index="speaker", columns="bucket", values="id", aggfunc="count", fill_value=0)
214
+
215
+ fig = px.imshow(pivot, aspect="auto", color_continuous_scale="Viridis")
216
+ fig.update_layout(
217
+ title=f"Actividad por speaker (buckets={bins})",
218
+ xaxis_title="Bucket temporal",
219
+ yaxis_title="Speaker",
220
+ height=520,
221
+ )
222
+ return fig
223
+
224
+
225
+ def plot_topic_shift_timeline(df: pd.DataFrame) -> go.Figure:
226
+ if df.empty or "is_topic_shift" not in df.columns:
227
+ return go.Figure()
228
+
229
+ df = df.copy()
230
+ df["shift"] = df["is_topic_shift"].astype(int)
231
+
232
+ fig = px.scatter(
233
+ df[df["shift"] == 1],
234
+ x="id",
235
+ y=["shift"] * len(df[df["shift"] == 1]),
236
+ color="speaker",
237
+ hover_data=["topic_label"],
238
+ )
239
+ fig.update_layout(
240
+ title="Timeline de Topic Shifts",
241
+ xaxis_title="Turno (id)",
242
+ yaxis_title="Shift (1)",
243
+ height=250,
244
+ showlegend=True,
245
+ )
246
+ return fig
247
+
248
+
249
+ def plot_reply_sankey(graph: dict, min_weight: int = 1) -> go.Figure:
250
+ nodes = graph.get("nodes", [])
251
+ links = graph.get("links", [])
252
+
253
+ speakers = [n["id"] for n in nodes if "id" in n]
254
+ idx = {s: i for i, s in enumerate(speakers)}
255
+
256
+ sources, targets, values = [], [], []
257
+ for l in links:
258
+ w = int(l.get("weight", 1))
259
+ if w < min_weight:
260
+ continue
261
+ s = l.get("source")
262
+ t = l.get("target")
263
+ if s in idx and t in idx:
264
+ sources.append(idx[s])
265
+ targets.append(idx[t])
266
+ values.append(w)
267
+
268
+ fig = go.Figure(
269
+ data=[
270
+ go.Sankey(
271
+ node=dict(label=speakers, pad=15, thickness=15),
272
+ link=dict(source=sources, target=targets, value=values),
273
+ )
274
+ ]
275
+ )
276
+ fig.update_layout(title="Sankey: quién responde a quién", height=520)
277
+ return fig