topiclayers 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,217 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import hashlib
5
+ import pickle
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import pandas as pd
11
+
12
+ from topiclayers.support.cache import cache_key
13
+ from topiclayers.schema import InteractionType, Post
14
+
15
+ _INTERACTION_REVERSE_MAP = {
16
+ InteractionType.REPOST: "retweeted",
17
+ InteractionType.QUOTE: "quoted",
18
+ InteractionType.REPLY: "replied_to",
19
+ }
20
+
21
+
22
+ def _ref_type(post: Post) -> str | None:
23
+ if post.interaction_type is None:
24
+ return None
25
+ return _INTERACTION_REVERSE_MAP.get(post.interaction_type, None)
26
+
27
+
28
+ class Dataset:
29
+ def __init__(
30
+ self,
31
+ posts: list[Post],
32
+ n_cop: str = "dataset",
33
+ file_user: Optional[Path] = None,
34
+ load_users: bool = True,
35
+ ):
36
+ self.posts = posts
37
+ self.n_cop = n_cop
38
+ self.file_user = Path(file_user) if file_user is not None else None
39
+ self.load_users = load_users
40
+ self.name = n_cop
41
+
42
+ self.df_tweets: Optional[pd.DataFrame] = None
43
+ self.df_users: Optional[pd.DataFrame] = None
44
+ self.df_original: Optional[pd.DataFrame] = None
45
+ self.df_retweets: Optional[pd.DataFrame] = None
46
+ self.df_quotes: Optional[pd.DataFrame] = None
47
+ self.df_reply: Optional[pd.DataFrame] = None
48
+ self.df_retweets_labeled: Optional[pd.DataFrame] = None
49
+
50
+ def process(
51
+ self,
52
+ cache_dir: Optional[Path] = None,
53
+ adapter_name: str = "unknown",
54
+ reporter=None,
55
+ ) -> None:
56
+ cache_dir = Path(cache_dir) if cache_dir is not None else Path("./cache")
57
+ data_dir = cache_dir / "data"
58
+ data_dir.mkdir(parents=True, exist_ok=True)
59
+
60
+ tweets_pkl = data_dir / f"tweets_{self.name}.pkl"
61
+ users_pkl = data_dir / f"users_{self.name}.pkl"
62
+ manifest_path = data_dir / f"manifest_{self.name}.json"
63
+
64
+ posts_hash = hashlib.sha256(
65
+ json.dumps([p.post_id for p in self.posts], sort_keys=True).encode()
66
+ ).hexdigest()[:16]
67
+ expected_key = cache_key(posts_hash, adapter_name, self.n_cop)
68
+
69
+ cache_valid = False
70
+ if manifest_path.exists():
71
+ try:
72
+ with open(manifest_path) as f:
73
+ manifest = json.load(f)
74
+ if manifest.get("key") == expected_key:
75
+ cache_valid = True
76
+ except Exception:
77
+ pass
78
+
79
+ if cache_valid and tweets_pkl.exists():
80
+ self.df_tweets = pd.read_pickle(tweets_pkl)
81
+ if users_pkl.exists():
82
+ self.df_users = pd.read_pickle(users_pkl)
83
+ else:
84
+ if tweets_pkl.exists():
85
+ print("Cache key mismatch — regenerating data cache.")
86
+ users_dict = self._load_user_dict() if self.file_user is not None else {}
87
+ tweets_dict = {}
88
+ for i, post in enumerate(self.posts):
89
+ if reporter is not None:
90
+ reporter.update(i + 1)
91
+ author_name = None
92
+ if users_dict and post.user_id in users_dict:
93
+ author_name = users_dict[post.user_id].get("username")
94
+
95
+ mentions_id_raw = post.extra.get("mentions_id", []) if isinstance(post.extra, dict) else []
96
+ mentions_id = [str(m) for m in mentions_id_raw] if mentions_id_raw else []
97
+ mentions_name = [str(m) for m in post.mentions] if post.mentions else []
98
+
99
+ tweets_dict[post.post_id] = {
100
+ "author": post.user_id,
101
+ "author_name": author_name,
102
+ "text": post.text,
103
+ "date": post.created_at,
104
+ "lang": post.lang,
105
+ "conversation_id": post.extra.get("conversation_id") if isinstance(post.extra, dict) else None,
106
+ "referenced_type": _ref_type(post),
107
+ "referenced_id": post.target_post_id,
108
+ "mentions_name": mentions_name,
109
+ "mentions_id": mentions_id,
110
+ "attachments": post.extra.get("attachments") if isinstance(post.extra, dict) else None,
111
+ "cop": self.n_cop,
112
+ }
113
+
114
+ self.df_tweets = pd.DataFrame(tweets_dict).T
115
+ self.df_tweets.index.name = "id"
116
+
117
+ if users_dict:
118
+ self.df_users = pd.DataFrame(users_dict).T
119
+ self.df_users.to_csv(data_dir / f"users_{self.name}.csv")
120
+ self.df_users.to_pickle(users_pkl)
121
+
122
+ self.df_tweets.to_csv(data_dir / f"tweets_{self.name}.csv")
123
+ self.df_tweets.to_pickle(tweets_pkl)
124
+
125
+ manifest = {
126
+ "key": expected_key,
127
+ "timestamp": datetime.now(timezone.utc).isoformat(),
128
+ "n_cop": self.n_cop,
129
+ "n_rows": len(self.df_tweets),
130
+ "n_users": len(self.df_users) if self.df_users is not None else 0,
131
+ "adapter": adapter_name,
132
+ }
133
+ with open(manifest_path, "w") as f:
134
+ json.dump(manifest, f, indent=2, default=str)
135
+
136
+ self._create_dataframes()
137
+
138
+ def _load_user_dict(self) -> dict:
139
+ import json
140
+
141
+ if self.file_user is None:
142
+ return {}
143
+ if not self.file_user.exists():
144
+ print(f"User file not found: {self.file_user} — skipping user metadata.")
145
+ return {}
146
+
147
+ users = {}
148
+ with open(self.file_user) as f:
149
+ for line in f:
150
+ line = line.strip()
151
+ if not line:
152
+ continue
153
+ obj = json.loads(line)
154
+ uid = str(obj.get("id", ""))
155
+ users[uid] = {
156
+ "username": obj.get("username", ""),
157
+ "tweet_count": obj.get("public_metrics", {}).get("tweet_count", 0),
158
+ "followers": obj.get("public_metrics", {}).get("followers_count", 0),
159
+ "following": obj.get("public_metrics", {}).get("following_count", 0),
160
+ }
161
+ return users
162
+
163
+ def _create_dataframes(self) -> None:
164
+ df = self.df_tweets
165
+ self.df_original = df[df["referenced_type"].isna()].copy()
166
+ self.df_retweets = df[df["referenced_type"] == "retweeted"].copy()
167
+ self.df_quotes = df[df["referenced_type"] == "quoted"].copy()
168
+ self.df_reply = df[df["referenced_type"] == "replied_to"].copy()
169
+
170
+ def update_df(self, df_labeled: pd.DataFrame) -> pd.DataFrame:
171
+ if "id" in df_labeled.columns and df_labeled.index.name != "id":
172
+ df_labeled = df_labeled.set_index("id")
173
+
174
+ df_retweets_copy = self.df_retweets.copy()
175
+ df_retweets_copy["topic"] = df_retweets_copy["referenced_id"]
176
+ self.df_retweets_labeled = pd.concat([df_labeled, df_retweets_copy])
177
+
178
+ topic_dict = self.df_retweets_labeled["topic"].to_dict()
179
+
180
+ resolved: dict = {}
181
+ unresolved_count = 0
182
+ for idx, value in topic_dict.items():
183
+ visited: set = set()
184
+ current = idx
185
+ val = value
186
+ while isinstance(val, str):
187
+ if val in visited:
188
+ val = None
189
+ unresolved_count += 1
190
+ break
191
+ visited.add(val)
192
+ if val not in topic_dict:
193
+ unresolved_count += 1
194
+ val = None
195
+ break
196
+ val = topic_dict[val]
197
+ resolved[idx] = val
198
+
199
+ self.df_retweets_labeled["topic"] = self.df_retweets_labeled.index.map(resolved)
200
+ print(f"Unresolved references: {unresolved_count}")
201
+ if unresolved_count > 0:
202
+ print(f" {unresolved_count} retweets could not be resolved to a topic. These will be excluded from the labeled output.")
203
+
204
+ self.df_retweets_labeled = self.df_retweets_labeled[
205
+ self.df_retweets_labeled["topic"].apply(lambda x: not isinstance(x, str))
206
+ ]
207
+ self.df_retweets_labeled = self.df_retweets_labeled[self.df_retweets_labeled["topic"].notna()]
208
+
209
+ return self.df_retweets_labeled
210
+
211
+ def _save_labeled_dataframe(self, cache_dir: Optional[Path] = None) -> None:
212
+ cache_dir = Path(cache_dir) if cache_dir is not None else Path("./cache")
213
+ data_dir = cache_dir / "data"
214
+ data_dir.mkdir(parents=True, exist_ok=True)
215
+ labeled_file = data_dir / f"retweet_labeled_{self.name}"
216
+ self.df_retweets_labeled.to_csv(f"{labeled_file}.csv")
217
+ self.df_retweets_labeled.to_pickle(f"{labeled_file}.pkl")
@@ -0,0 +1,281 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import networkx as nx
6
+ import pandas as pd
7
+
8
+
9
+ class NetworkCreator:
10
+ def __init__(self, df: pd.DataFrame, name: str, output_dir: Path):
11
+ self.df = df
12
+ self.name = name
13
+ self.output_dir = Path(output_dir)
14
+ self.graph_dir = self.output_dir / "networks"
15
+ self.proj_graphs: dict = {}
16
+ self.ml_network = None
17
+
18
+ def create_retweet_network(self, reporter=None) -> nx.DiGraph:
19
+ edges, missing_refs = self._aggregate_edges(self.df, reporter=reporter)
20
+
21
+ G = nx.DiGraph()
22
+ G.add_nodes_from(self.df["author"].unique())
23
+ G.add_weighted_edges_from(
24
+ (src, tgt, int(w)) for src, tgt, w in edges.itertuples(index=False, name=None)
25
+ )
26
+
27
+ if missing_refs:
28
+ print(f" {missing_refs} retweets reference tweets not in dataset — skipped.")
29
+
30
+ self._save_graph(G, "retweet")
31
+ return G
32
+
33
+ @staticmethod
34
+ def _aggregate_edges(df: pd.DataFrame, reporter=None) -> tuple[pd.DataFrame, int]:
35
+ """Aggregate retweet rows into unique (author, target_author, weight) edges.
36
+
37
+ Returns the aggregated edge frame and the number of retweet rows whose
38
+ referenced tweet is not present in ``df`` (unresolvable target).
39
+ """
40
+ if reporter is not None:
41
+ reporter.update(0, total=len(df))
42
+
43
+ rt = df.loc[df["referenced_id"].notna(), ["author", "referenced_id"]]
44
+ targets = rt["referenced_id"].map(df["author"])
45
+ missing_refs = int(targets.isna().sum())
46
+
47
+ edges = (
48
+ pd.DataFrame({"author": rt["author"], "target": targets})
49
+ .dropna(subset=["target"])
50
+ .groupby(["author", "target"])
51
+ .size()
52
+ .reset_index(name="weight")
53
+ )
54
+
55
+ if reporter is not None:
56
+ reporter.update(len(df), total=len(df))
57
+ return edges, missing_refs
58
+
59
+ def create_retweet_ml(self, reporter=None):
60
+ import uunet.multinet as ml
61
+
62
+ topics = self.df["topic"].unique()
63
+ topics = topics[topics != -1]
64
+
65
+ if len(topics) == 0:
66
+ raise ValueError(
67
+ "Cannot create multilayer network: all topics are -1 (outliers). "
68
+ "This means BERTopic found no topic clusters. "
69
+ "Try: <1> reduce min_topic_size, <2> use a larger dataset, "
70
+ "<3> check the 'text' column is not empty."
71
+ )
72
+
73
+ ml_network = ml.empty()
74
+ dropped_edges_total = 0
75
+
76
+ rt = self.df.loc[
77
+ self.df["referenced_id"].notna(), ["author", "referenced_id", "topic"]
78
+ ]
79
+ total_work = int((self.df["topic"] != -1).sum())
80
+ done = 0
81
+
82
+ for topic, df_tmp in self.df.groupby("topic"):
83
+ if topic == -1:
84
+ continue
85
+
86
+ G = nx.DiGraph()
87
+ G.add_nodes_from(df_tmp["author"].unique())
88
+
89
+ rt_topic = rt[rt["topic"] == topic]
90
+ edges, dropped = self._aggregate_slice(df_tmp, rt_topic)
91
+ dropped_edges_total += dropped
92
+
93
+ G.add_weighted_edges_from(
94
+ (src, tgt, int(w)) for src, tgt, w in edges.itertuples(index=False, name=None)
95
+ )
96
+
97
+ done += len(df_tmp)
98
+ if reporter is not None:
99
+ reporter.update(done, total=total_work)
100
+
101
+ ml.add_nx_layer(ml_network, G, str(topic))
102
+
103
+ if dropped_edges_total:
104
+ print(f" {dropped_edges_total} edges dropped (referenced tweet missing in topic slice).")
105
+
106
+ self._save_multilayer(ml_network)
107
+ self.ml_network = ml_network
108
+ return ml_network
109
+
110
+ @staticmethod
111
+ def _aggregate_slice(
112
+ df_slice: pd.DataFrame, rt_slice: pd.DataFrame
113
+ ) -> tuple[pd.DataFrame, int]:
114
+ """Aggregate retweet rows of one topic slice into weighted edges.
115
+
116
+ Returns (edges, dropped) where ``dropped`` counts rows whose referenced
117
+ tweet is not present in ``df_slice``.
118
+ """
119
+ targets = rt_slice["referenced_id"].map(df_slice["author"])
120
+ dropped = int(targets.isna().sum())
121
+ edges = (
122
+ pd.DataFrame({"author": rt_slice["author"], "target": targets})
123
+ .dropna(subset=["target"])
124
+ .groupby(["author", "target"])
125
+ .size()
126
+ .reset_index(name="weight")
127
+ )
128
+ return edges, dropped
129
+
130
+ def create_ttnetwork(self, project: bool = True):
131
+ A = self.df["author"].unique()
132
+ M = self.df.index.tolist()
133
+ x = self.df["text"].to_dict()
134
+ topics = self.df["topic"].to_dict()
135
+ author = self.df["author"].to_dict()
136
+ is_retweet = self.df["referenced_type"].to_dict()
137
+ is_retweet = {k: "original" if v is None else v for k, v in is_retweet.items()}
138
+
139
+ g = nx.DiGraph()
140
+
141
+ g.add_nodes_from(A, bipartite=0)
142
+ g.add_nodes_from(M, bipartite=1)
143
+
144
+ edges = list(zip(self.df["author"], self.df.index))
145
+ ref_edges = [
146
+ (idx, str(ref_id))
147
+ for idx, ref_id in zip(self.df.index, self.df["referenced_id"])
148
+ if ref_id is not None and not (isinstance(ref_id, float) and pd.isna(ref_id))
149
+ ]
150
+
151
+ men_edges = []
152
+ if "mentions_name" in self.df.columns:
153
+ exploded = self.df["mentions_name"].explode().dropna()
154
+ exploded = exploded[exploded.map(lambda m: isinstance(m, str))]
155
+ men_edges = list(zip(exploded.index, exploded))
156
+
157
+ g.add_edges_from(edges, weight=10)
158
+ g.add_edges_from(ref_edges, weight=1)
159
+
160
+ nodes_to_remove = [node for node in g.nodes if "bipartite" not in g.nodes[node]]
161
+ g.remove_nodes_from(nodes_to_remove)
162
+
163
+ date_lookup = self.df["date"].to_dict()
164
+
165
+ t = {}
166
+ for e in g.edges():
167
+ if e[1] in date_lookup:
168
+ t[e] = date_lookup[e[1]]
169
+
170
+ g.add_edges_from(men_edges)
171
+
172
+ nodes_to_set = [node for node in g.nodes if "bipartite" not in g.nodes[node]]
173
+ for node in nodes_to_set:
174
+ g.nodes[node].setdefault("bipartite", 0)
175
+
176
+ nx.set_edge_attributes(g, t, "date")
177
+ nx.set_node_attributes(g, x, "text")
178
+ nx.set_node_attributes(g, topics, "topics")
179
+ nx.set_node_attributes(g, author, "author")
180
+ nx.set_node_attributes(g, is_retweet, "is_retweet")
181
+
182
+ for node in g.nodes():
183
+ if "label" not in g.nodes[node]:
184
+ g.nodes[node]["label"] = str(node)
185
+ if "author" not in g.nodes[node] and g.nodes[node].get("bipartite") == 0:
186
+ g.nodes[node]["author"] = str(node)
187
+
188
+ self._save_graph(g, "ttt")
189
+
190
+ if project:
191
+ self._project_network(g, title=self.name)
192
+
193
+ return g
194
+
195
+ def _project_network(self, nx_graph=None, path=None, title="") -> dict:
196
+ from igraph import Graph
197
+
198
+ def recursive_explore(graph, node, start_node, previous_node=None, edges=None, topic=None, depth=0):
199
+ neighbors = graph.neighborhood(node.index, mode="out")
200
+
201
+ if edges is None:
202
+ edges = {}
203
+
204
+ if node["bipartite"] == 0.0:
205
+ if depth == 2:
206
+ edges.setdefault(topic, []).append((start_node["label"], node["label"]))
207
+ return edges
208
+ elif depth > 2:
209
+ edges.setdefault(topic, []).append((start_node["label"], previous_node["author"]))
210
+ return edges
211
+ else:
212
+ if topic is None:
213
+ topic = node["topics"]
214
+ if len(neighbors) == 1:
215
+ edges.setdefault(topic, []).append((start_node["label"], node["author"]))
216
+ return edges
217
+
218
+ for neighbor_idx in neighbors[1:]:
219
+ new_node = graph.vs[neighbor_idx]
220
+ recursive_explore(
221
+ graph, node=new_node, previous_node=node,
222
+ start_node=start_node, depth=depth + 1, edges=edges, topic=topic,
223
+ )
224
+
225
+ return edges
226
+
227
+ if path is not None:
228
+ g = Graph.Read_GML(path)
229
+ elif nx_graph is not None:
230
+ g = Graph.from_networkx(nx_graph)
231
+ g.vs["label"] = [str(n) for n in nx_graph.nodes()]
232
+ g.vs["_nx_name"] = [str(n) for n in nx_graph.nodes()]
233
+ else:
234
+ raise ValueError(
235
+ "Provide either a path to a GML file or a networkx graph. "
236
+ "Call create_ttnetwork(project=True) to project the bipartite TTT network."
237
+ )
238
+
239
+ all_edges: dict = {}
240
+
241
+ for n in g.vs.select(bipartite_eq=0):
242
+ result = recursive_explore(g, n, start_node=n)
243
+ if result is None:
244
+ continue
245
+ for key in set(list(all_edges.keys()) + list(result.keys())):
246
+ existing = all_edges.get(key, [])
247
+ incoming = result.get(key, [])
248
+ if incoming is not None:
249
+ all_edges[key] = list(set(existing + incoming))
250
+
251
+ deduped = {e: list(set(all_edges[e])) for e in all_edges}
252
+ deduped.pop(None, None)
253
+
254
+ self._save_projected(deduped)
255
+ return self.proj_graphs
256
+
257
+ def _save_projected(self, edges: dict) -> None:
258
+ prj_dir = self.graph_dir / "projected"
259
+ prj_dir.mkdir(parents=True, exist_ok=True)
260
+
261
+ for t, e in edges.items():
262
+ t_str = str(t)
263
+ if t_str.lower() == "nan":
264
+ continue
265
+ g = nx.from_edgelist(e, create_using=nx.DiGraph())
266
+ self.proj_graphs[t_str] = g
267
+ nx.write_gml(g, prj_dir / f"{self.name}__prj_{t_str}.gml")
268
+
269
+ def _save_graph(self, G, title: str) -> Path:
270
+ self.graph_dir.mkdir(parents=True, exist_ok=True)
271
+ filename = self.graph_dir / f"{self.name}_{title}.gml"
272
+ nx.write_gml(G, filename)
273
+ return filename
274
+
275
+ def _save_multilayer(self, ml_network) -> Path:
276
+ import uunet.multinet as ml
277
+
278
+ self.graph_dir.mkdir(parents=True, exist_ok=True)
279
+ filename = self.graph_dir / f"{self.name}_retweet_network_ml.gml"
280
+ ml.write(ml_network, file=str(filename))
281
+ return filename